@patchstack/connect 0.3.31 → 0.3.33
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 +175 -18
- package/README.md +7 -2
- package/dist/{chunk-LLKP5EJS.js → chunk-3G2I6QL6.js} +1 -1
- package/dist/chunk-3G2I6QL6.js.map +1 -0
- package/dist/cli.js +143 -23
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +2645 -1417
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +316 -0
- package/dist/protect.d.ts +134 -18
- package/dist/protect.edge.js +2723 -1477
- package/dist/protect.edge.js.map +3 -4
- package/dist/protect.js +2645 -1421
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-KNZQBC4V.js → refresh-manifest-SWCPQ52Z.js} +2 -2
- package/dist/refresh-manifest-SWCPQ52Z.js.map +1 -0
- package/package.json +44 -11
- package/dist/chunk-LLKP5EJS.js.map +0 -1
- package/dist/refresh-manifest-KNZQBC4V.js.map +0 -1
package/dist/protect.edge.js
CHANGED
|
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
|
5
5
|
var __getProtoOf = Object.getPrototypeOf;
|
|
6
6
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
7
|
var __commonJS = (cb, mod) => function __require() {
|
|
8
|
-
|
|
8
|
+
try {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
} catch (e) {
|
|
11
|
+
throw mod = 0, e;
|
|
12
|
+
}
|
|
9
13
|
};
|
|
10
14
|
var __copyProps = (to, from, except, desc) => {
|
|
11
15
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
@@ -31,6 +35,220 @@ var require_ps_edge_stub = __commonJS({
|
|
|
31
35
|
}
|
|
32
36
|
});
|
|
33
37
|
|
|
38
|
+
// src/protect/client-ip.js
|
|
39
|
+
var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
40
|
+
var POLICY_FIELDS = Object.freeze(["peers", "hops", "header", "isTrusted"]);
|
|
41
|
+
var IP_SOURCES = Object.freeze(["runtime", "trusted-proxy", "unavailable"]);
|
|
42
|
+
var DEFAULT_FORWARDED_HEADER = "x-forwarded-for";
|
|
43
|
+
function parseIpv4(value) {
|
|
44
|
+
const parts = value.split(".");
|
|
45
|
+
if (parts.length !== 4) return null;
|
|
46
|
+
const octets = [];
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
49
|
+
if (part.length > 1 && part.startsWith("0")) return null;
|
|
50
|
+
const n = Number(part);
|
|
51
|
+
if (n > 255) return null;
|
|
52
|
+
octets.push(n);
|
|
53
|
+
}
|
|
54
|
+
return octets;
|
|
55
|
+
}
|
|
56
|
+
function parseIpv6(value) {
|
|
57
|
+
if (value === "" || value.includes(":::")) return null;
|
|
58
|
+
let head = value;
|
|
59
|
+
let embedded = null;
|
|
60
|
+
const lastColon = head.lastIndexOf(":");
|
|
61
|
+
const tail = lastColon === -1 ? "" : head.slice(lastColon + 1);
|
|
62
|
+
if (tail.includes(".")) {
|
|
63
|
+
embedded = parseIpv4(tail);
|
|
64
|
+
if (embedded === null) return null;
|
|
65
|
+
head = head.slice(0, lastColon + 1) + "0:0";
|
|
66
|
+
}
|
|
67
|
+
const halves = head.split("::");
|
|
68
|
+
if (halves.length > 2) return null;
|
|
69
|
+
const readGroups = (text) => {
|
|
70
|
+
if (text === "") return [];
|
|
71
|
+
const groups2 = [];
|
|
72
|
+
for (const g of text.split(":")) {
|
|
73
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
|
|
74
|
+
groups2.push(Number.parseInt(g, 16));
|
|
75
|
+
}
|
|
76
|
+
return groups2;
|
|
77
|
+
};
|
|
78
|
+
const left = readGroups(halves[0]);
|
|
79
|
+
const right = halves.length === 2 ? readGroups(halves[1]) : [];
|
|
80
|
+
if (left === null || right === null) return null;
|
|
81
|
+
let groups;
|
|
82
|
+
if (halves.length === 2) {
|
|
83
|
+
const fill = 8 - (left.length + right.length);
|
|
84
|
+
if (fill < 1) return null;
|
|
85
|
+
groups = [...left, ...new Array(fill).fill(0), ...right];
|
|
86
|
+
} else {
|
|
87
|
+
groups = left;
|
|
88
|
+
}
|
|
89
|
+
if (groups.length !== 8) return null;
|
|
90
|
+
if (embedded !== null) {
|
|
91
|
+
groups[6] = embedded[0] << 8 | embedded[1];
|
|
92
|
+
groups[7] = embedded[2] << 8 | embedded[3];
|
|
93
|
+
}
|
|
94
|
+
return groups;
|
|
95
|
+
}
|
|
96
|
+
function toNumeric(value) {
|
|
97
|
+
const text = String(value).trim();
|
|
98
|
+
if (text === "") return null;
|
|
99
|
+
const zoneParts = text.split("%");
|
|
100
|
+
if (zoneParts.length > 2) return null;
|
|
101
|
+
const addr = zoneParts[0];
|
|
102
|
+
const zone = zoneParts.length === 2 ? zoneParts[1] : null;
|
|
103
|
+
if (zone !== null && (!ZONE.test(zone) || !addr.includes(":"))) return null;
|
|
104
|
+
const v4 = parseIpv4(addr);
|
|
105
|
+
if (v4 !== null) {
|
|
106
|
+
return {
|
|
107
|
+
bits: 32,
|
|
108
|
+
value: v4.reduce((acc, octet) => acc << 8n | BigInt(octet), 0n),
|
|
109
|
+
canonical: v4.join(".")
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (!addr.includes(":")) return null;
|
|
113
|
+
const v6 = parseIpv6(addr);
|
|
114
|
+
if (v6 === null) return null;
|
|
115
|
+
const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535;
|
|
116
|
+
if (mapped) {
|
|
117
|
+
if (zone !== null) return null;
|
|
118
|
+
const octets = [v6[6] >> 8, v6[6] & 255, v6[7] >> 8, v6[7] & 255];
|
|
119
|
+
return { bits: 32, value: BigInt(v6[6]) << 16n | BigInt(v6[7]), canonical: octets.join(".") };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
bits: 128,
|
|
123
|
+
value: v6.reduce((acc, group) => acc << 16n | BigInt(group), 0n),
|
|
124
|
+
// The zone survives: without it a link-local address does not identify an interface.
|
|
125
|
+
canonical: zone === null ? canonicalIpv6(v6) : `${canonicalIpv6(v6)}%${zone}`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function canonicalIpv6(groups) {
|
|
129
|
+
let bestStart = -1;
|
|
130
|
+
let bestLength = 0;
|
|
131
|
+
let runStart = -1;
|
|
132
|
+
for (let i = 0; i <= groups.length; i++) {
|
|
133
|
+
if (i < groups.length && groups[i] === 0) {
|
|
134
|
+
if (runStart === -1) runStart = i;
|
|
135
|
+
} else if (runStart !== -1) {
|
|
136
|
+
const length = i - runStart;
|
|
137
|
+
if (length > bestLength) {
|
|
138
|
+
bestLength = length;
|
|
139
|
+
bestStart = runStart;
|
|
140
|
+
}
|
|
141
|
+
runStart = -1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const hex = groups.map((g) => g.toString(16));
|
|
145
|
+
if (bestLength < 2) return hex.join(":");
|
|
146
|
+
const head = hex.slice(0, bestStart).join(":");
|
|
147
|
+
const tail = hex.slice(bestStart + bestLength).join(":");
|
|
148
|
+
return `${head}::${tail}`;
|
|
149
|
+
}
|
|
150
|
+
function canonicalIp(value) {
|
|
151
|
+
return toNumeric(value)?.canonical ?? null;
|
|
152
|
+
}
|
|
153
|
+
function parseCidr(entry) {
|
|
154
|
+
if (typeof entry !== "string") return null;
|
|
155
|
+
if (entry.includes("%")) return null;
|
|
156
|
+
const parts = entry.trim().split("/");
|
|
157
|
+
if (parts.length > 2) return null;
|
|
158
|
+
const numeric = toNumeric(parts[0]);
|
|
159
|
+
if (numeric === null) return null;
|
|
160
|
+
let length = numeric.bits;
|
|
161
|
+
if (parts.length === 2) {
|
|
162
|
+
if (!/^\d{1,3}$/.test(parts[1])) return null;
|
|
163
|
+
length = Number(parts[1]);
|
|
164
|
+
if (length > numeric.bits) return null;
|
|
165
|
+
}
|
|
166
|
+
const shift = BigInt(numeric.bits - length);
|
|
167
|
+
return { bits: numeric.bits, network: numeric.value >> shift, shift };
|
|
168
|
+
}
|
|
169
|
+
function readTrustPolicy(trustedProxy) {
|
|
170
|
+
if (trustedProxy === null || typeof trustedProxy !== "object" || Array.isArray(trustedProxy)) return null;
|
|
171
|
+
const has = (field) => Object.hasOwn(trustedProxy, field);
|
|
172
|
+
for (const key of Object.keys(trustedProxy)) {
|
|
173
|
+
if (!POLICY_FIELDS.includes(key)) return null;
|
|
174
|
+
}
|
|
175
|
+
if (has("header") && (typeof trustedProxy.header !== "string" || trustedProxy.header.trim() === "")) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
if (has("hops") && (!Number.isInteger(trustedProxy.hops) || trustedProxy.hops < 1)) return null;
|
|
179
|
+
if (has("isTrusted") && typeof trustedProxy.isTrusted !== "function") return null;
|
|
180
|
+
const header = has("header") ? trustedProxy.header.trim().toLowerCase() : DEFAULT_FORWARDED_HEADER;
|
|
181
|
+
const cidrs = [];
|
|
182
|
+
if (has("peers")) {
|
|
183
|
+
if (!Array.isArray(trustedProxy.peers) || trustedProxy.peers.length === 0) return null;
|
|
184
|
+
for (const entry of trustedProxy.peers) {
|
|
185
|
+
const parsed = parseCidr(entry);
|
|
186
|
+
if (parsed === null) return null;
|
|
187
|
+
cidrs.push(parsed);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const hops = has("hops") ? trustedProxy.hops : null;
|
|
191
|
+
const predicate = has("isTrusted") ? trustedProxy.isTrusted : null;
|
|
192
|
+
if (cidrs.length === 0 && hops === null && predicate === null) return null;
|
|
193
|
+
return { header, cidrs, hops, predicate };
|
|
194
|
+
}
|
|
195
|
+
function isTrustedPeer(policy, value) {
|
|
196
|
+
const numeric = toNumeric(value);
|
|
197
|
+
if (numeric === null) return false;
|
|
198
|
+
for (const cidr of policy.cidrs) {
|
|
199
|
+
if (cidr.bits === numeric.bits && numeric.value >> cidr.shift === cidr.network) return true;
|
|
200
|
+
}
|
|
201
|
+
if (policy.predicate !== null) {
|
|
202
|
+
try {
|
|
203
|
+
if (policy.predicate(String(value)) === true) return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
var UNSPECIFIED = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
|
|
211
|
+
function identifyingIp(value) {
|
|
212
|
+
const canonical = canonicalIp(value);
|
|
213
|
+
return canonical === null || UNSPECIFIED.has(canonical) ? null : canonical;
|
|
214
|
+
}
|
|
215
|
+
function chainFrom(headers, header) {
|
|
216
|
+
if (headers === null || typeof headers !== "object" || !Object.hasOwn(headers, header)) return [];
|
|
217
|
+
const raw = headers[header];
|
|
218
|
+
const value = Array.isArray(raw) ? raw.join(",") : raw;
|
|
219
|
+
if (typeof value !== "string" || value.trim() === "") return [];
|
|
220
|
+
return value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
221
|
+
}
|
|
222
|
+
function resolveClientIp(input) {
|
|
223
|
+
const headers = input.headers ?? {};
|
|
224
|
+
const peer = identifyingIp(input.peer);
|
|
225
|
+
const policy = readTrustPolicy(input.trustedProxy);
|
|
226
|
+
if (peer === null) return { ip: null, source: "unavailable" };
|
|
227
|
+
if (policy === null) return { ip: peer, source: "runtime" };
|
|
228
|
+
const gatedByAddress = policy.cidrs.length > 0 || policy.predicate !== null;
|
|
229
|
+
const peerTrusted = gatedByAddress ? isTrustedPeer(policy, peer) : policy.hops !== null;
|
|
230
|
+
if (!peerTrusted) return { ip: peer, source: "runtime" };
|
|
231
|
+
const chain = chainFrom(headers, policy.header);
|
|
232
|
+
if (chain.length === 0) return { ip: peer, source: "runtime" };
|
|
233
|
+
if (policy.hops !== null) {
|
|
234
|
+
const index = chain.length - policy.hops;
|
|
235
|
+
const candidate = index >= 0 ? chain[index] : void 0;
|
|
236
|
+
const canonical = identifyingIp(candidate);
|
|
237
|
+
return canonical === null ? { ip: peer, source: "runtime" } : { ip: canonical, source: "trusted-proxy" };
|
|
238
|
+
}
|
|
239
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
240
|
+
const canonical = identifyingIp(chain[i]);
|
|
241
|
+
if (canonical === null) return { ip: peer, source: "runtime" };
|
|
242
|
+
if (!isTrustedPeer(policy, canonical)) return { ip: canonical, source: "trusted-proxy" };
|
|
243
|
+
}
|
|
244
|
+
return { ip: peer, source: "runtime" };
|
|
245
|
+
}
|
|
246
|
+
function clientIpFields(resolved) {
|
|
247
|
+
const established = resolved?.source === "runtime" || resolved?.source === "trusted-proxy";
|
|
248
|
+
const address = identifyingIp(resolved?.ip);
|
|
249
|
+
return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
|
|
250
|
+
}
|
|
251
|
+
|
|
34
252
|
// src/protect/safe-origin.js
|
|
35
253
|
function isSafeOrigin(value) {
|
|
36
254
|
try {
|
|
@@ -387,7 +605,7 @@ var RequestResolver = class {
|
|
|
387
605
|
return req.headers?.host ? [req.headers.host] : [];
|
|
388
606
|
case "REMOTE_ADDR":
|
|
389
607
|
case "ip":
|
|
390
|
-
return [req.ip
|
|
608
|
+
return [typeof req.ip === "string" ? req.ip : ""];
|
|
391
609
|
case "CONTENT_TYPE":
|
|
392
610
|
return req.headers?.["content-type"] ? [req.headers["content-type"]] : [];
|
|
393
611
|
case "CONTENT_LENGTH":
|
|
@@ -728,14 +946,29 @@ function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isR
|
|
|
728
946
|
});
|
|
729
947
|
return "{" + parts.join(",") + "}";
|
|
730
948
|
}
|
|
949
|
+
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
950
|
+
function requestField(req, key) {
|
|
951
|
+
if (req === null || typeof req !== "object") return void 0;
|
|
952
|
+
if (Object.hasOwn(req, key)) return req[key];
|
|
953
|
+
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
954
|
+
let holder = Object.getPrototypeOf(req);
|
|
955
|
+
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
956
|
+
if (holder === null || holder === Object.prototype) return void 0;
|
|
957
|
+
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
958
|
+
if (typeof descriptor?.get !== "function") return void 0;
|
|
959
|
+
return req[key];
|
|
960
|
+
}
|
|
731
961
|
function normalizeRequest(req, options = {}) {
|
|
732
|
-
const
|
|
962
|
+
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
963
|
+
const body = requestField(req, "body");
|
|
964
|
+
const url = requestField(req, "url");
|
|
965
|
+
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
733
966
|
return {
|
|
734
|
-
query: normalizeObject(req
|
|
735
|
-
body: normalizeObject(
|
|
736
|
-
headers: normalizeObject(req
|
|
737
|
-
url: normalize(
|
|
738
|
-
originalUrl: normalize(req
|
|
967
|
+
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
968
|
+
body: normalizeObject(body || {}, options),
|
|
969
|
+
headers: normalizeObject(requestField(req, "headers") || {}, options),
|
|
970
|
+
url: normalize(url || "", options),
|
|
971
|
+
originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
|
|
739
972
|
_rawBody: rawBody
|
|
740
973
|
};
|
|
741
974
|
}
|
|
@@ -1297,7 +1530,12 @@ var RuleEngine = class {
|
|
|
1297
1530
|
return {
|
|
1298
1531
|
blocked: true,
|
|
1299
1532
|
rule,
|
|
1300
|
-
message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}
|
|
1533
|
+
message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`,
|
|
1534
|
+
// The resolver this match was decided by, for whatever needs to report on it. A consumer
|
|
1535
|
+
// that re-read the request instead would be reading it a second time: a getter, a stream or
|
|
1536
|
+
// anything else that answers once can give a different value, and evidence that disagrees
|
|
1537
|
+
// with the match it belongs to is worse than none.
|
|
1538
|
+
resolver
|
|
1301
1539
|
};
|
|
1302
1540
|
}
|
|
1303
1541
|
} catch (err) {
|
|
@@ -1392,1459 +1630,1706 @@ var RuleEngine = class {
|
|
|
1392
1630
|
}
|
|
1393
1631
|
};
|
|
1394
1632
|
|
|
1395
|
-
// src/
|
|
1396
|
-
var
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1633
|
+
// src/protect/rules/contract.js
|
|
1634
|
+
var SOURCES = Object.freeze({
|
|
1635
|
+
raw: Object.freeze({ keyed: false }),
|
|
1636
|
+
all: Object.freeze({ keyed: false }),
|
|
1637
|
+
false: Object.freeze({ keyed: false }),
|
|
1638
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
1639
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
1640
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
1641
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
1642
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
1643
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
1644
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
1645
|
+
files: Object.freeze({
|
|
1646
|
+
keyed: true,
|
|
1647
|
+
keys: "any",
|
|
1648
|
+
wildcard: true,
|
|
1649
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
1650
|
+
}),
|
|
1651
|
+
server: Object.freeze({
|
|
1652
|
+
keyed: true,
|
|
1653
|
+
keys: Object.freeze([
|
|
1654
|
+
"REQUEST_URI",
|
|
1655
|
+
"REQUEST_METHOD",
|
|
1656
|
+
"HTTP_USER_AGENT",
|
|
1657
|
+
"HTTP_REFERER",
|
|
1658
|
+
"HTTP_HOST",
|
|
1659
|
+
"REMOTE_ADDR",
|
|
1660
|
+
"ip",
|
|
1661
|
+
"CONTENT_TYPE",
|
|
1662
|
+
"CONTENT_LENGTH"
|
|
1663
|
+
]),
|
|
1664
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
1665
|
+
}),
|
|
1666
|
+
response: Object.freeze({
|
|
1667
|
+
keyed: true,
|
|
1668
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
1669
|
+
key_prefixes: Object.freeze(["header."])
|
|
1670
|
+
}),
|
|
1671
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
1672
|
+
});
|
|
1673
|
+
var MATCH_TYPES = Object.freeze([
|
|
1674
|
+
"array_in_array",
|
|
1675
|
+
"array_key_value",
|
|
1676
|
+
"contains",
|
|
1677
|
+
"cors_reflected",
|
|
1678
|
+
"cross_origin",
|
|
1679
|
+
"ctype_alnum",
|
|
1680
|
+
"ctype_digit",
|
|
1681
|
+
"ctype_special",
|
|
1682
|
+
"equals",
|
|
1683
|
+
"equals_strict",
|
|
1684
|
+
"hostname",
|
|
1685
|
+
"in_array",
|
|
1686
|
+
"inline_js_xss",
|
|
1687
|
+
"inline_xss",
|
|
1688
|
+
"internal_host",
|
|
1689
|
+
"is_numeric",
|
|
1690
|
+
"isset",
|
|
1691
|
+
"jwt_claim_equals",
|
|
1692
|
+
"less_than",
|
|
1693
|
+
"more_than",
|
|
1694
|
+
"not_contains",
|
|
1695
|
+
"not_in_array",
|
|
1696
|
+
"off_origin",
|
|
1697
|
+
"quotes",
|
|
1698
|
+
"regex",
|
|
1699
|
+
"stripos"
|
|
1700
|
+
]);
|
|
1701
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
1702
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
1703
|
+
"isset",
|
|
1704
|
+
"quotes",
|
|
1705
|
+
"is_numeric",
|
|
1706
|
+
"ctype_alnum",
|
|
1707
|
+
"ctype_digit",
|
|
1708
|
+
"ctype_special",
|
|
1709
|
+
"inline_xss",
|
|
1710
|
+
"inline_js_xss",
|
|
1711
|
+
"internal_host",
|
|
1712
|
+
"cross_origin",
|
|
1713
|
+
"off_origin",
|
|
1714
|
+
"cors_reflected"
|
|
1715
|
+
]);
|
|
1716
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
1717
|
+
"positive-number",
|
|
1718
|
+
"boolean",
|
|
1719
|
+
"non-empty-string",
|
|
1720
|
+
"regex-literal",
|
|
1721
|
+
"string-or-string-list",
|
|
1722
|
+
"scalar",
|
|
1723
|
+
"string",
|
|
1724
|
+
"numeric",
|
|
1725
|
+
"scalar-or-non-empty-list",
|
|
1726
|
+
"non-empty-list",
|
|
1727
|
+
"object",
|
|
1728
|
+
"non-empty-object",
|
|
1729
|
+
"non-empty-string-list",
|
|
1730
|
+
"header-name-map",
|
|
1731
|
+
"header-name-list",
|
|
1732
|
+
"cookie-flags"
|
|
1733
|
+
]);
|
|
1734
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
1735
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
1736
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
1737
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
1738
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
1739
|
+
array_key_value: Object.freeze({
|
|
1740
|
+
required: Object.freeze(["key", "match"]),
|
|
1741
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
1742
|
+
}),
|
|
1743
|
+
// Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
|
|
1744
|
+
// `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
|
|
1745
|
+
// exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
|
|
1746
|
+
// oversized, claim missing, claim not a string, or a different value — is a no-match.
|
|
1747
|
+
jwt_claim_equals: Object.freeze({
|
|
1748
|
+
required: Object.freeze(["claim", "value"]),
|
|
1749
|
+
shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
|
|
1750
|
+
}),
|
|
1751
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
1752
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
1753
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
1754
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
1755
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
1756
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
1757
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
1758
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
1759
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
1760
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
1761
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
1762
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
1763
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
1764
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
1765
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
1766
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
1767
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
1768
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
1769
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
1770
|
+
});
|
|
1771
|
+
function operandShapeProblem(shape, value) {
|
|
1772
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
1773
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
1774
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
1775
|
+
switch (shape) {
|
|
1776
|
+
case "positive-number":
|
|
1777
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
|
|
1778
|
+
case "boolean":
|
|
1779
|
+
return typeof value === "boolean" ? null : "must be true or false";
|
|
1780
|
+
case "non-empty-string":
|
|
1781
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
1782
|
+
case "regex-literal":
|
|
1783
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
1784
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
1785
|
+
case "string-or-string-list":
|
|
1786
|
+
if (isFilledString(value)) return null;
|
|
1787
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
1788
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
1789
|
+
case "scalar":
|
|
1790
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
1791
|
+
case "string":
|
|
1792
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
1793
|
+
case "numeric":
|
|
1794
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
1795
|
+
case "scalar-or-non-empty-list":
|
|
1796
|
+
if (isScalar(value)) return null;
|
|
1797
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
1798
|
+
return "must be a scalar, or a non-empty list of them";
|
|
1799
|
+
case "non-empty-list":
|
|
1800
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
1801
|
+
case "header-name-map": {
|
|
1802
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
1803
|
+
const names = Object.keys(value);
|
|
1804
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
1805
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
1806
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
1807
|
+
}
|
|
1808
|
+
case "header-name-list": {
|
|
1809
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
1810
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
1811
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
1812
|
+
}
|
|
1813
|
+
case "cookie-flags": {
|
|
1814
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
1815
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
1816
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
1817
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
1818
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
1819
|
+
return `flag "${flag}" must be true or false`;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
const sameSite = value.sameSite;
|
|
1823
|
+
const sameSiteOff = sameSite === false;
|
|
1824
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
1825
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
1826
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
1830
|
+
return "turns off every flag, so it would harden nothing";
|
|
1831
|
+
}
|
|
1832
|
+
return null;
|
|
1833
|
+
}
|
|
1834
|
+
case "object":
|
|
1835
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
1836
|
+
case "non-empty-object":
|
|
1837
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
1838
|
+
case "non-empty-string-list":
|
|
1839
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
1840
|
+
default:
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1411
1843
|
}
|
|
1412
|
-
var
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1844
|
+
var MUTATIONS = Object.freeze([
|
|
1845
|
+
"base64_decode",
|
|
1846
|
+
"getArrayValues",
|
|
1847
|
+
"htmlentitydecode",
|
|
1848
|
+
"intval",
|
|
1849
|
+
"json_decode",
|
|
1850
|
+
"json_encode",
|
|
1851
|
+
"urldecode"
|
|
1852
|
+
]);
|
|
1853
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
1854
|
+
var ACTIONS = Object.freeze([
|
|
1855
|
+
"block",
|
|
1856
|
+
"redact",
|
|
1857
|
+
"encode",
|
|
1858
|
+
"set-header",
|
|
1859
|
+
"remove-header",
|
|
1860
|
+
"harden-cookie"
|
|
1861
|
+
]);
|
|
1862
|
+
var RULE_PROPERTY_SHAPES = Object.freeze({
|
|
1863
|
+
max_bytes: "positive-number",
|
|
1864
|
+
bypass_limit: "boolean",
|
|
1865
|
+
ensure: "boolean",
|
|
1866
|
+
prefilter: "non-empty-string-list"
|
|
1867
|
+
});
|
|
1868
|
+
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
1869
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
1870
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
1871
|
+
"id",
|
|
1872
|
+
"rule_id",
|
|
1873
|
+
"title",
|
|
1874
|
+
"category",
|
|
1875
|
+
"phase",
|
|
1876
|
+
"action",
|
|
1877
|
+
"rule_v2",
|
|
1878
|
+
"when",
|
|
1879
|
+
"message",
|
|
1880
|
+
"enforcement",
|
|
1881
|
+
"source_revision",
|
|
1882
|
+
"prefilter",
|
|
1883
|
+
"max_bytes",
|
|
1884
|
+
"bypass_limit",
|
|
1885
|
+
"set_headers",
|
|
1886
|
+
"remove_headers",
|
|
1887
|
+
"cookie_flags",
|
|
1888
|
+
"ensure",
|
|
1889
|
+
"capture"
|
|
1890
|
+
]);
|
|
1891
|
+
var CAPTURE_VERSION = 1;
|
|
1892
|
+
var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
|
|
1893
|
+
var CAPTURE_RAW_CHARS_MAX = 512;
|
|
1894
|
+
var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
|
|
1895
|
+
function captureProblem(capture) {
|
|
1896
|
+
if (capture === void 0 || capture === null) return null;
|
|
1897
|
+
if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
|
|
1898
|
+
for (const key of Object.keys(capture)) {
|
|
1899
|
+
if (!CAPTURE_KEYS.includes(key)) return `capture has no "${key}" \u2014 it takes ${CAPTURE_KEYS.join(", ")}`;
|
|
1900
|
+
}
|
|
1901
|
+
if (!Object.hasOwn(capture, "version")) return "capture must name the version it was written against";
|
|
1902
|
+
if (capture.version !== CAPTURE_VERSION) {
|
|
1903
|
+
return `capture version ${String(capture.version)} is not one this guard understands`;
|
|
1904
|
+
}
|
|
1905
|
+
if (!Object.hasOwn(capture, "raw_chars")) return "capture must say what it is opting into";
|
|
1906
|
+
const chars = capture.raw_chars;
|
|
1907
|
+
if (typeof chars !== "number" || !Number.isInteger(chars) || chars <= 0) {
|
|
1908
|
+
return "capture.raw_chars must be a positive whole number of characters";
|
|
1418
1909
|
}
|
|
1419
|
-
|
|
1420
|
-
const credentials = parsePulseAuth(config.pulseAuth);
|
|
1421
|
-
if (credentials === null) return null;
|
|
1422
|
-
inflight = (async () => {
|
|
1423
|
-
try {
|
|
1424
|
-
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
1425
|
-
method: "POST",
|
|
1426
|
-
headers: {
|
|
1427
|
-
"Content-Type": "application/json",
|
|
1428
|
-
Accept: "application/json",
|
|
1429
|
-
"User-Agent": "@patchstack/connect"
|
|
1430
|
-
},
|
|
1431
|
-
body: JSON.stringify({
|
|
1432
|
-
grant_type: "client_credentials",
|
|
1433
|
-
client_id: credentials.clientId,
|
|
1434
|
-
client_secret: credentials.clientSecret
|
|
1435
|
-
}),
|
|
1436
|
-
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1437
|
-
});
|
|
1438
|
-
if (!response.ok) return null;
|
|
1439
|
-
const body = await response.json();
|
|
1440
|
-
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
1441
|
-
const expiresIn = Number(body.expires_in);
|
|
1442
|
-
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
1443
|
-
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
1444
|
-
return body.access_token;
|
|
1445
|
-
} catch {
|
|
1446
|
-
return null;
|
|
1447
|
-
} finally {
|
|
1448
|
-
inflight = null;
|
|
1449
|
-
}
|
|
1450
|
-
})();
|
|
1451
|
-
return inflight;
|
|
1910
|
+
return null;
|
|
1452
1911
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1912
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
1913
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
1914
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
1915
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
1916
|
+
"set-header": Object.freeze({
|
|
1917
|
+
required: Object.freeze(["set_headers"]),
|
|
1918
|
+
defaulted: Object.freeze(["ensure"]),
|
|
1919
|
+
phases: Object.freeze(["response"]),
|
|
1920
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
1921
|
+
}),
|
|
1922
|
+
"remove-header": Object.freeze({
|
|
1923
|
+
required: Object.freeze(["remove_headers"]),
|
|
1924
|
+
defaulted: Object.freeze([]),
|
|
1925
|
+
phases: Object.freeze(["response"]),
|
|
1926
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
1927
|
+
}),
|
|
1928
|
+
"harden-cookie": Object.freeze({
|
|
1929
|
+
required: Object.freeze([]),
|
|
1930
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
1931
|
+
phases: Object.freeze(["response"]),
|
|
1932
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
1933
|
+
})
|
|
1934
|
+
});
|
|
1935
|
+
var LIMITS = Object.freeze({
|
|
1936
|
+
maxRules: 5e3,
|
|
1937
|
+
maxWhitelists: 2e3,
|
|
1938
|
+
maxConditionsPerRule: 250,
|
|
1939
|
+
maxNestingDepth: 12,
|
|
1940
|
+
maxRegexLength: 1e3,
|
|
1941
|
+
maxValueLength: 8192
|
|
1942
|
+
});
|
|
1943
|
+
var GROUP_PARAMETER = "rules";
|
|
1944
|
+
function isGroup(condition) {
|
|
1945
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
1456
1946
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
#siteUuid;
|
|
1464
|
-
#timeoutMs;
|
|
1465
|
-
#baseUrl;
|
|
1466
|
-
#cacheTtl;
|
|
1467
|
-
#cache = null;
|
|
1468
|
-
#cacheTime = null;
|
|
1469
|
-
#ttlEffective = 0;
|
|
1470
|
-
#etag;
|
|
1471
|
-
#pulseAuth;
|
|
1472
|
-
#reportsDetections;
|
|
1473
|
-
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
|
|
1474
|
-
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1475
|
-
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1476
|
-
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1477
|
-
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1478
|
-
this.#etag = etag ?? null;
|
|
1479
|
-
this.#pulseAuth = pulseAuth ?? null;
|
|
1480
|
-
this.#reportsDetections = reportsDetections === true;
|
|
1481
|
-
if (!this.#siteUuid) {
|
|
1482
|
-
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
async getRules() {
|
|
1486
|
-
const now = Date.now();
|
|
1487
|
-
if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
|
|
1488
|
-
return this.#cache;
|
|
1489
|
-
}
|
|
1490
|
-
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1491
|
-
try {
|
|
1492
|
-
const auth = await pulseAuthHeader(
|
|
1493
|
-
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
1494
|
-
fetch
|
|
1495
|
-
);
|
|
1496
|
-
const headers = { Accept: "application/json", ...auth };
|
|
1497
|
-
if (this.#reportsDetections && typeof auth.Authorization === "string") {
|
|
1498
|
-
headers["X-Patchstack-Detections"] = "enabled";
|
|
1499
|
-
}
|
|
1500
|
-
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1501
|
-
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1502
|
-
if (response.status === 304) {
|
|
1503
|
-
this.#touch(now);
|
|
1504
|
-
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1505
|
-
}
|
|
1506
|
-
if (!response.ok) {
|
|
1507
|
-
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1508
|
-
}
|
|
1509
|
-
const data = await response.json();
|
|
1510
|
-
if (!data || !Array.isArray(data.firewall)) {
|
|
1511
|
-
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1512
|
-
}
|
|
1513
|
-
const result = {
|
|
1514
|
-
success: true,
|
|
1515
|
-
notModified: false,
|
|
1516
|
-
etag: response.headers?.get?.("etag") ?? null,
|
|
1517
|
-
firewall: data.firewall,
|
|
1518
|
-
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
1519
|
-
whitelist_keys: data.whitelist_keys ?? {},
|
|
1520
|
-
...enforcementField(data)
|
|
1521
|
-
};
|
|
1522
|
-
this.#cache = result;
|
|
1523
|
-
this.#etag = result.etag;
|
|
1524
|
-
this.#touch(now);
|
|
1525
|
-
return result;
|
|
1526
|
-
} catch (err) {
|
|
1527
|
-
return {
|
|
1528
|
-
success: false,
|
|
1529
|
-
error: err.name === "TimeoutError" ? "Request timed out" : err.message,
|
|
1530
|
-
firewall: [],
|
|
1531
|
-
whitelists: [],
|
|
1532
|
-
whitelist_keys: {}
|
|
1533
|
-
};
|
|
1534
|
-
}
|
|
1947
|
+
function conditionShapeProblem(condition) {
|
|
1948
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
1949
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
1950
|
+
const nested = Array.isArray(condition.rules);
|
|
1951
|
+
if (named && !nested) {
|
|
1952
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
1535
1953
|
}
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
|
|
1954
|
+
if (nested && !named) {
|
|
1955
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
1539
1956
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1957
|
+
if (named && nested) {
|
|
1958
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
1959
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
1960
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
1544
1961
|
}
|
|
1545
|
-
|
|
1546
|
-
function enforcementField(data) {
|
|
1547
|
-
if (!data || typeof data !== "object") return {};
|
|
1548
|
-
const v = data.enforcement ?? data.mode;
|
|
1549
|
-
return v === "block" || v === "dry-run" ? { enforcement: v } : {};
|
|
1962
|
+
return null;
|
|
1550
1963
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
request.headers.forEach((value, key) => {
|
|
1560
|
-
headers[key.toLowerCase()] = value;
|
|
1561
|
-
});
|
|
1562
|
-
const query = {};
|
|
1563
|
-
for (const [key, value] of url.searchParams) {
|
|
1564
|
-
if (key in query) {
|
|
1565
|
-
query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
|
|
1566
|
-
} else {
|
|
1567
|
-
query[key] = value;
|
|
1964
|
+
function parameterProblem(parameter) {
|
|
1965
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
1966
|
+
if (Array.isArray(parameter)) {
|
|
1967
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
1968
|
+
for (const member of parameter) {
|
|
1969
|
+
if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
|
|
1970
|
+
const problem = parameterProblem(member);
|
|
1971
|
+
if (problem !== null) return problem;
|
|
1568
1972
|
}
|
|
1973
|
+
return null;
|
|
1569
1974
|
}
|
|
1570
|
-
|
|
1571
|
-
if (
|
|
1572
|
-
|
|
1975
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
1976
|
+
if (parameter === GROUP_PARAMETER) {
|
|
1977
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
1573
1978
|
}
|
|
1574
|
-
const
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
files = parsed.files;
|
|
1979
|
+
const dot = parameter.indexOf(".");
|
|
1980
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
1981
|
+
const source = SOURCES[name];
|
|
1982
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
1983
|
+
if (dot === -1) {
|
|
1984
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
1581
1985
|
}
|
|
1582
|
-
|
|
1583
|
-
const
|
|
1584
|
-
return {
|
|
1585
|
-
|
|
1586
|
-
url: uri,
|
|
1587
|
-
originalUrl: uri,
|
|
1588
|
-
query,
|
|
1589
|
-
body,
|
|
1590
|
-
files,
|
|
1591
|
-
headers,
|
|
1592
|
-
ip: forwarded.split(",")[0].trim(),
|
|
1593
|
-
cookies: parseCookies(headers.cookie),
|
|
1594
|
-
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
1595
|
-
// drops, so prototype-pollution rules on `raw` are robust.
|
|
1596
|
-
_rawBody: rawBody
|
|
1597
|
-
};
|
|
1986
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
1987
|
+
const key = parameter.slice(dot + 1);
|
|
1988
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
1989
|
+
return keyProblem(name, source, key);
|
|
1598
1990
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
clone = request.clone();
|
|
1603
|
-
} catch {
|
|
1604
|
-
return "";
|
|
1991
|
+
function keyProblem(name, source, key) {
|
|
1992
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
1993
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
1605
1994
|
}
|
|
1606
|
-
const
|
|
1607
|
-
if (
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1995
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
1996
|
+
if (source.keys === "any") return null;
|
|
1997
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
1998
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
1999
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
2000
|
+
}
|
|
2001
|
+
const accepted = [
|
|
2002
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
2003
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2004
|
+
].join(", ");
|
|
2005
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2006
|
+
}
|
|
2007
|
+
function matchProblem(match, parameter) {
|
|
2008
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
2009
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
2010
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
2011
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
2012
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
2013
|
+
return `match type "${match.type}" needs a parameter`;
|
|
2014
|
+
}
|
|
2015
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
2016
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
2017
|
+
}
|
|
2018
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
2019
|
+
if (operands) {
|
|
2020
|
+
for (const required of operands.required) {
|
|
2021
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
2022
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
1619
2023
|
}
|
|
1620
|
-
} catch {
|
|
1621
|
-
return "";
|
|
1622
2024
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
return ""
|
|
2025
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
2026
|
+
if (match[operand] === void 0) continue;
|
|
2027
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
2028
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
1627
2029
|
}
|
|
2030
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
2031
|
+
return `match type "${match.type}" needs a value`;
|
|
1628
2032
|
}
|
|
1629
|
-
|
|
1630
|
-
const text = await clone.text();
|
|
1631
|
-
return text.length > max ? text.slice(0, max) : text;
|
|
1632
|
-
} catch {
|
|
1633
|
-
return "";
|
|
1634
|
-
}
|
|
2033
|
+
return null;
|
|
1635
2034
|
}
|
|
1636
|
-
function
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
for (const
|
|
1640
|
-
|
|
1641
|
-
|
|
2035
|
+
function mutationsProblem(mutations) {
|
|
2036
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
2037
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
2038
|
+
for (const mutation of mutations) {
|
|
2039
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
2040
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
2041
|
+
}
|
|
1642
2042
|
}
|
|
1643
|
-
return
|
|
2043
|
+
return null;
|
|
1644
2044
|
}
|
|
1645
|
-
function
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
const
|
|
1649
|
-
|
|
1650
|
-
const
|
|
1651
|
-
if (
|
|
1652
|
-
|
|
1653
|
-
if (boundary) return parseMultipart(rawBody, boundary);
|
|
1654
|
-
return { body: {}, files: void 0 };
|
|
2045
|
+
function whenProblem(when) {
|
|
2046
|
+
if (when === void 0 || when === null) return null;
|
|
2047
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
2048
|
+
const keys = Object.keys(when);
|
|
2049
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
2050
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
2051
|
+
if (unknown.length > 0) {
|
|
2052
|
+
return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
|
|
1655
2053
|
}
|
|
1656
|
-
if (
|
|
1657
|
-
|
|
1658
|
-
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1659
|
-
return { body, files: void 0 };
|
|
2054
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
2055
|
+
return "when.path must be a non-empty string";
|
|
1660
2056
|
}
|
|
1661
|
-
if (
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
} catch {
|
|
1666
|
-
}
|
|
2057
|
+
if ("method" in when) {
|
|
2058
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
2059
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
2060
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
1667
2061
|
}
|
|
1668
|
-
return
|
|
2062
|
+
return null;
|
|
1669
2063
|
}
|
|
1670
|
-
function
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const name = /name="([^"]*)"/i.exec(disposition)?.[1];
|
|
1680
|
-
if (name == null) continue;
|
|
1681
|
-
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
1682
|
-
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
1683
|
-
if (filename !== void 0) {
|
|
1684
|
-
const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
|
|
1685
|
-
const file = { filename, type: partType, content };
|
|
1686
|
-
files[name] = name in files ? [].concat(files[name], file) : file;
|
|
1687
|
-
} else {
|
|
1688
|
-
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
2064
|
+
function isNonEmptyString(value) {
|
|
2065
|
+
return typeof value === "string" && value.trim() !== "";
|
|
2066
|
+
}
|
|
2067
|
+
function nullPropertyProblem(rule) {
|
|
2068
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2069
|
+
for (const property of RULE_PROPERTIES) {
|
|
2070
|
+
if (NULL_EXEMPT_PROPERTIES.includes(property)) continue;
|
|
2071
|
+
if (property in rule && rule[property] === null) {
|
|
2072
|
+
return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
|
|
1689
2073
|
}
|
|
1690
2074
|
}
|
|
1691
|
-
return
|
|
2075
|
+
return null;
|
|
1692
2076
|
}
|
|
1693
|
-
function
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
2077
|
+
function rulePropertyProblem(rule) {
|
|
2078
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2079
|
+
for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
|
|
2080
|
+
if (rule[property] === void 0 || rule[property] === null) continue;
|
|
2081
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2082
|
+
if (problem) return `"${property}" ${problem}`;
|
|
1697
2083
|
}
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
function actionProblem(action, rule) {
|
|
2087
|
+
if (action === void 0 || action === null) return null;
|
|
2088
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2089
|
+
const spec = ACTION_PROPERTIES[action];
|
|
2090
|
+
for (const required of spec?.required ?? []) {
|
|
2091
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2092
|
+
return `action "${action}" needs "${required}"`;
|
|
1702
2093
|
}
|
|
1703
|
-
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
1704
2094
|
}
|
|
1705
|
-
|
|
2095
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2096
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2097
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2098
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2099
|
+
}
|
|
2100
|
+
const phase = rule?.phase ?? "request";
|
|
2101
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2102
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2103
|
+
}
|
|
2104
|
+
return null;
|
|
1706
2105
|
}
|
|
1707
2106
|
|
|
1708
|
-
// src/protect/
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
url = new URL(req.url || "/", "http://localhost");
|
|
1722
|
-
} catch {
|
|
1723
|
-
url = new URL("http://localhost/");
|
|
2107
|
+
// src/protect/rules/validate.js
|
|
2108
|
+
var LIMITS2 = LIMITS;
|
|
2109
|
+
var PHASES2 = new Set(PHASES);
|
|
2110
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
2111
|
+
function validateBundle(bundle, opts = {}) {
|
|
2112
|
+
const rejected = [];
|
|
2113
|
+
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2114
|
+
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2115
|
+
const firewall = [];
|
|
2116
|
+
for (const rule of inFirewall) {
|
|
2117
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
2118
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2119
|
+
continue;
|
|
1724
2120
|
}
|
|
2121
|
+
const reason = enforceableRuleProblem(rule);
|
|
2122
|
+
if (reason) rejected.push({ id: idOf(rule), reason });
|
|
2123
|
+
else firewall.push(rule);
|
|
1725
2124
|
}
|
|
1726
|
-
const
|
|
1727
|
-
for (const
|
|
1728
|
-
if (
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
query[key] = value;
|
|
2125
|
+
const whitelists = [];
|
|
2126
|
+
for (const wl of inWhitelists) {
|
|
2127
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
2128
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
2129
|
+
continue;
|
|
1732
2130
|
}
|
|
2131
|
+
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
2132
|
+
rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
const reason = conditionsProblem(wl?.rule_v2);
|
|
2136
|
+
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
|
|
2137
|
+
else whitelists.push(wl);
|
|
1733
2138
|
}
|
|
1734
|
-
const contentType = headers["content-type"] || "";
|
|
1735
|
-
let body = {};
|
|
1736
|
-
let files;
|
|
1737
|
-
if (rawBody) {
|
|
1738
|
-
const parsed = parseBody(rawBody, contentType);
|
|
1739
|
-
body = parsed.body;
|
|
1740
|
-
files = parsed.files;
|
|
1741
|
-
}
|
|
1742
|
-
const uri = url.pathname + url.search;
|
|
1743
|
-
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
1744
2139
|
return {
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
originalUrl: uri,
|
|
1748
|
-
query,
|
|
1749
|
-
body,
|
|
1750
|
-
files,
|
|
1751
|
-
headers,
|
|
1752
|
-
ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
|
|
1753
|
-
cookies: parseCookies2(headers.cookie),
|
|
1754
|
-
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
1755
|
-
_rawBody: rawBody
|
|
2140
|
+
bundle: { ...bundle, firewall, whitelists },
|
|
2141
|
+
rejected
|
|
1756
2142
|
};
|
|
1757
2143
|
}
|
|
1758
|
-
function
|
|
1759
|
-
const
|
|
1760
|
-
|
|
1761
|
-
|
|
2144
|
+
function idOf(rule) {
|
|
2145
|
+
const id = rule?.id ?? rule?.rule_id;
|
|
2146
|
+
return id === void 0 || id === null ? "(unidentified)" : String(id);
|
|
2147
|
+
}
|
|
2148
|
+
function enforceableRuleProblem(rule) {
|
|
2149
|
+
if (!rule || typeof rule !== "object") return "not an object";
|
|
2150
|
+
const nullReason = nullPropertyProblem(rule);
|
|
2151
|
+
if (nullReason) return nullReason;
|
|
2152
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2153
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
2154
|
+
if (actionReason) return actionReason;
|
|
2155
|
+
const scopeReason = whenProblem(rule.when);
|
|
2156
|
+
if (scopeReason) return scopeReason;
|
|
2157
|
+
const propertyReason = rulePropertyProblem(rule);
|
|
2158
|
+
if (propertyReason) return propertyReason;
|
|
2159
|
+
return conditionsProblem(rule.rule_v2);
|
|
2160
|
+
}
|
|
2161
|
+
function conditionsProblem(conditions, depth = 0) {
|
|
2162
|
+
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2163
|
+
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2164
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
2165
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
2166
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
1762
2167
|
}
|
|
1763
|
-
for (const
|
|
1764
|
-
const
|
|
1765
|
-
if (
|
|
2168
|
+
for (const c of conditions) {
|
|
2169
|
+
const shapeReason = conditionShapeProblem(c);
|
|
2170
|
+
if (shapeReason) return shapeReason;
|
|
2171
|
+
if (isGroup(c)) {
|
|
2172
|
+
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2173
|
+
if (nested) return nested;
|
|
1766
2174
|
continue;
|
|
1767
2175
|
}
|
|
1768
|
-
|
|
2176
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
2177
|
+
if (parameterReason) return parameterReason;
|
|
2178
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
2179
|
+
if (mutationReason) return mutationReason;
|
|
2180
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
2181
|
+
if (matchReason) return matchReason;
|
|
2182
|
+
const m = c.match;
|
|
2183
|
+
if (m.type === "regex") {
|
|
2184
|
+
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2185
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2186
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2187
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2188
|
+
}
|
|
2189
|
+
if (m.match) {
|
|
2190
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2191
|
+
if (nested) return nested;
|
|
2192
|
+
}
|
|
1769
2193
|
}
|
|
1770
|
-
return
|
|
2194
|
+
return null;
|
|
1771
2195
|
}
|
|
1772
2196
|
|
|
1773
|
-
// src/protect/
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2197
|
+
// src/protect/capture-plan.js
|
|
2198
|
+
var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
|
|
2199
|
+
var CAPTURE_LIMITS = Object.freeze({
|
|
2200
|
+
/** Named and prefix values in one event, together. Raw has its own allowance. */
|
|
2201
|
+
capturedValues: 10,
|
|
2202
|
+
/** Characters of any single captured value. */
|
|
2203
|
+
valueChars: 512,
|
|
2204
|
+
/** Resolved values one prefix permission may contribute. */
|
|
2205
|
+
prefixValues: 5
|
|
2206
|
+
});
|
|
2207
|
+
function parametersOf(rule) {
|
|
2208
|
+
const out = /* @__PURE__ */ new Set();
|
|
2209
|
+
const add = (parameter) => {
|
|
2210
|
+
if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
|
|
1783
2211
|
};
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
const { isIP } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
|
|
1789
|
-
if (typeof resolveLookup === "function" && typeof isIP === "function") {
|
|
1790
|
-
screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
|
|
1791
|
-
}
|
|
1792
|
-
} catch {
|
|
1793
|
-
screen = null;
|
|
2212
|
+
const collect = (parameter) => {
|
|
2213
|
+
if (Array.isArray(parameter)) {
|
|
2214
|
+
for (const member of parameter) add(member);
|
|
2215
|
+
return;
|
|
1794
2216
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
const
|
|
1798
|
-
if (!
|
|
1799
|
-
|
|
1800
|
-
|
|
2217
|
+
add(parameter);
|
|
2218
|
+
};
|
|
2219
|
+
const walk = (conditions, depth) => {
|
|
2220
|
+
if (!Array.isArray(conditions) || depth > 20) return;
|
|
2221
|
+
for (const condition of conditions) {
|
|
2222
|
+
if (!condition || typeof condition !== "object") continue;
|
|
2223
|
+
collect(condition.parameter);
|
|
2224
|
+
if (Array.isArray(condition.rules)) walk(condition.rules, depth + 1);
|
|
1801
2225
|
}
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2226
|
+
};
|
|
2227
|
+
walk(rule?.rule_v2, 0);
|
|
2228
|
+
return [...out];
|
|
2229
|
+
}
|
|
2230
|
+
function rawOptIn(rule) {
|
|
2231
|
+
if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
|
|
2232
|
+
const capture = rule.capture;
|
|
2233
|
+
if (capture === null || typeof capture !== "object") return null;
|
|
2234
|
+
if (captureProblem(capture) !== null) return null;
|
|
2235
|
+
return { chars: Math.min(capture.raw_chars, CAPTURE_RAW_CHARS_MAX) };
|
|
2236
|
+
}
|
|
2237
|
+
var NOTHING = Object.freeze({
|
|
2238
|
+
named: Object.freeze([]),
|
|
2239
|
+
prefixes: Object.freeze([]),
|
|
2240
|
+
raw: null,
|
|
2241
|
+
limits: CAPTURE_LIMITS
|
|
2242
|
+
});
|
|
2243
|
+
function derivePlan(rule) {
|
|
2244
|
+
const named = /* @__PURE__ */ new Set();
|
|
2245
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
2246
|
+
if (enforceableRuleProblem(rule) !== null) return NOTHING;
|
|
2247
|
+
const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
|
|
2248
|
+
for (const parameter of parameters) {
|
|
2249
|
+
const dot = parameter.indexOf(".");
|
|
2250
|
+
if (dot === -1) continue;
|
|
2251
|
+
const source = parameter.slice(0, dot);
|
|
2252
|
+
const key = parameter.slice(dot + 1);
|
|
2253
|
+
if (SOURCES[source]?.keyed !== true || NEVER_CAPTURABLE.has(source)) continue;
|
|
2254
|
+
if (key.endsWith("*")) {
|
|
2255
|
+
const prefix = key.slice(0, -1);
|
|
2256
|
+
if (prefix !== "") prefixes.add(`${source}.${prefix}`);
|
|
2257
|
+
continue;
|
|
1818
2258
|
}
|
|
2259
|
+
named.add(`${source}.${key}`);
|
|
2260
|
+
}
|
|
2261
|
+
const raw = rawOptIn(rule);
|
|
2262
|
+
return Object.freeze({
|
|
2263
|
+
named: Object.freeze([...named].sort()),
|
|
2264
|
+
prefixes: Object.freeze([...prefixes].sort()),
|
|
2265
|
+
raw: raw === null ? null : Object.freeze(raw),
|
|
2266
|
+
limits: CAPTURE_LIMITS
|
|
1819
2267
|
});
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
1853
|
-
);
|
|
1854
|
-
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
1855
|
-
if (!location) return resp;
|
|
1856
|
-
if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
|
|
1857
|
-
const next = new URL(location, url).href;
|
|
1858
|
-
if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
|
|
1859
|
-
method = "GET";
|
|
1860
|
-
body = void 0;
|
|
1861
|
-
headers.delete("content-type");
|
|
1862
|
-
headers.delete("content-length");
|
|
1863
|
-
}
|
|
1864
|
-
if (new URL(next).origin !== new URL(url).origin) {
|
|
1865
|
-
headers.delete("authorization");
|
|
1866
|
-
headers.delete("cookie");
|
|
1867
|
-
}
|
|
1868
|
-
await screenUrl(next, method);
|
|
1869
|
-
url = next;
|
|
2268
|
+
}
|
|
2269
|
+
function permitsAnything(plan) {
|
|
2270
|
+
return plan.named.length > 0 || plan.prefixes.length > 0 || plan.raw !== null;
|
|
2271
|
+
}
|
|
2272
|
+
var LANES = Object.freeze([2166136261, 16777619, 2654435769, 2246822507]);
|
|
2273
|
+
function planReference(plan) {
|
|
2274
|
+
const limits = plan?.limits ?? {};
|
|
2275
|
+
const canonical = JSON.stringify([
|
|
2276
|
+
[...plan?.named ?? []].sort(),
|
|
2277
|
+
[...plan?.prefixes ?? []].sort(),
|
|
2278
|
+
plan?.raw?.chars ?? 0,
|
|
2279
|
+
Object.entries(limits).map(([key, value]) => [key, value]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
2280
|
+
]);
|
|
2281
|
+
const digest = LANES.map((base) => {
|
|
2282
|
+
let hash = base >>> 0;
|
|
2283
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
2284
|
+
hash ^= canonical.charCodeAt(i);
|
|
2285
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
2286
|
+
}
|
|
2287
|
+
return hash.toString(16).padStart(8, "0");
|
|
2288
|
+
}).join("");
|
|
2289
|
+
return `cp2-${digest}`;
|
|
2290
|
+
}
|
|
2291
|
+
function createPlanCache() {
|
|
2292
|
+
const byRule = /* @__PURE__ */ new WeakMap();
|
|
2293
|
+
let derivations = 0;
|
|
2294
|
+
return {
|
|
2295
|
+
for(rule) {
|
|
2296
|
+
if (rule === null || typeof rule !== "object") {
|
|
2297
|
+
const plan2 = derivePlan(rule);
|
|
2298
|
+
derivations += 1;
|
|
2299
|
+
return Object.freeze({ plan: plan2, reference: planReference(plan2) });
|
|
1870
2300
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
2301
|
+
const hit = byRule.get(rule);
|
|
2302
|
+
if (hit) return hit;
|
|
2303
|
+
const plan = derivePlan(rule);
|
|
2304
|
+
derivations += 1;
|
|
2305
|
+
const entry = Object.freeze({ plan, reference: planReference(plan) });
|
|
2306
|
+
byRule.set(rule, entry);
|
|
2307
|
+
return entry;
|
|
2308
|
+
},
|
|
2309
|
+
/** How many plans have been derived — the observable difference between a hit and a miss. */
|
|
2310
|
+
get derivations() {
|
|
2311
|
+
return derivations;
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
function captureValues(plan, resolver) {
|
|
2316
|
+
const nothing = { values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null };
|
|
2317
|
+
if (!plan || !permitsAnything(plan)) return nothing;
|
|
2318
|
+
if (!resolver || typeof resolver.resolve !== "function") return { ...nothing, unavailable: true };
|
|
2319
|
+
const limits = plan.limits ?? CAPTURE_LIMITS;
|
|
2320
|
+
const values = [];
|
|
2321
|
+
let omitted = 0;
|
|
2322
|
+
let unsupported = 0;
|
|
2323
|
+
let failed = 0;
|
|
2324
|
+
let attempted = 0;
|
|
2325
|
+
const read = (parameter) => {
|
|
2326
|
+
attempted += 1;
|
|
1879
2327
|
try {
|
|
1880
|
-
const
|
|
1881
|
-
|
|
1882
|
-
if (restore) restores.push(restore);
|
|
2328
|
+
const resolved = resolver.resolve(parameter);
|
|
2329
|
+
return Array.isArray(resolved) ? resolved : [];
|
|
1883
2330
|
} catch {
|
|
2331
|
+
failed += 1;
|
|
2332
|
+
return null;
|
|
1884
2333
|
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
}
|
|
2334
|
+
};
|
|
2335
|
+
const classify = (value) => {
|
|
2336
|
+
const text = asText(value);
|
|
2337
|
+
if (text === null) {
|
|
2338
|
+
unsupported += 1;
|
|
2339
|
+
return null;
|
|
1892
2340
|
}
|
|
2341
|
+
return text;
|
|
1893
2342
|
};
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
const originalGet = http.get;
|
|
1899
|
-
const wrap = (original) => function(...args) {
|
|
1900
|
-
const target = extractHttpTarget(args);
|
|
1901
|
-
if (target && block(target.url, target.host, target.method)) {
|
|
1902
|
-
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
|
|
2343
|
+
const record = (parameter, text) => {
|
|
2344
|
+
if (values.length >= limits.capturedValues) {
|
|
2345
|
+
omitted += 1;
|
|
2346
|
+
return false;
|
|
1903
2347
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2348
|
+
const capped = text.length > limits.valueChars;
|
|
2349
|
+
values.push({
|
|
2350
|
+
parameter,
|
|
2351
|
+
value: capped ? text.slice(0, limits.valueChars) : text,
|
|
2352
|
+
...capped ? { truncated: true } : {}
|
|
2353
|
+
});
|
|
2354
|
+
return true;
|
|
2355
|
+
};
|
|
2356
|
+
for (const parameter of plan.named) {
|
|
2357
|
+
for (const value of read(parameter) ?? []) {
|
|
2358
|
+
const text = classify(value);
|
|
2359
|
+
if (text !== null) record(parameter, text);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
for (const prefix of plan.prefixes) {
|
|
2363
|
+
const pattern = `${prefix}*`;
|
|
2364
|
+
let taken = 0;
|
|
2365
|
+
for (const value of read(pattern) ?? []) {
|
|
2366
|
+
const text = classify(value);
|
|
2367
|
+
if (text === null) continue;
|
|
2368
|
+
if (taken >= limits.prefixValues) {
|
|
2369
|
+
omitted += 1;
|
|
2370
|
+
continue;
|
|
1908
2371
|
}
|
|
2372
|
+
if (record(pattern, text)) taken += 1;
|
|
1909
2373
|
}
|
|
1910
|
-
return original.apply(this, args);
|
|
1911
|
-
};
|
|
1912
|
-
const guardedRequest = wrap(originalRequest);
|
|
1913
|
-
http.request = guardedRequest;
|
|
1914
|
-
let guardedGet;
|
|
1915
|
-
if (typeof originalGet === "function") {
|
|
1916
|
-
guardedGet = wrap(originalGet);
|
|
1917
|
-
http.get = guardedGet;
|
|
1918
2374
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
if (
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
}
|
|
1926
|
-
function extractHttpTarget(args) {
|
|
1927
|
-
const first = args[0];
|
|
1928
|
-
try {
|
|
1929
|
-
if (typeof first === "string" || first instanceof URL) {
|
|
1930
|
-
const url = new URL(String(first));
|
|
1931
|
-
const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
1932
|
-
return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
|
|
2375
|
+
let raw = null;
|
|
2376
|
+
if (plan.raw !== null) {
|
|
2377
|
+
const text = asText((read("raw") ?? [])[0]);
|
|
2378
|
+
if (text !== null) {
|
|
2379
|
+
const capped = text.length > plan.raw.chars;
|
|
2380
|
+
raw = { value: capped ? text.slice(0, plan.raw.chars) : text, ...capped ? { truncated: true } : {} };
|
|
1933
2381
|
}
|
|
1934
|
-
if (first && typeof first === "object") {
|
|
1935
|
-
const host = normalizeHost(first.hostname || first.host) || "localhost";
|
|
1936
|
-
const protocol = first.protocol || "http:";
|
|
1937
|
-
const port = first.port ? `:${first.port}` : "";
|
|
1938
|
-
const path = first.path || "/";
|
|
1939
|
-
return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
|
|
1940
|
-
}
|
|
1941
|
-
} catch {
|
|
1942
2382
|
}
|
|
2383
|
+
const unavailable = attempted > 0 && failed === attempted;
|
|
2384
|
+
return { values, omitted, unsupported, failed, unavailable, raw };
|
|
2385
|
+
}
|
|
2386
|
+
function asText(value) {
|
|
2387
|
+
if (typeof value === "string") return value;
|
|
2388
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
2389
|
+
if (typeof value === "boolean") return String(value);
|
|
1943
2390
|
return null;
|
|
1944
2391
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2392
|
+
|
|
2393
|
+
// src/pulse-token.ts
|
|
2394
|
+
var TOKEN_SKEW_MS = 6e4;
|
|
2395
|
+
function buildTokenUrl(manifestEndpoint) {
|
|
2396
|
+
const url = new URL(manifestEndpoint);
|
|
2397
|
+
const path = url.pathname.replace(/\/$/, "");
|
|
2398
|
+
url.pathname = path.endsWith("/manifest") ? `${path.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
|
|
2399
|
+
url.search = "";
|
|
2400
|
+
url.hash = "";
|
|
2401
|
+
return url.toString();
|
|
1951
2402
|
}
|
|
1952
|
-
function
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
return
|
|
2403
|
+
function parsePulseAuth(credential) {
|
|
2404
|
+
const index = credential.lastIndexOf("-");
|
|
2405
|
+
if (index <= 0 || index === credential.length - 1) return null;
|
|
2406
|
+
const clientId = credential.slice(index + 1);
|
|
2407
|
+
if (!/^\d+$/.test(clientId)) return null;
|
|
2408
|
+
return { clientId, clientSecret: credential.slice(0, index) };
|
|
1958
2409
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2410
|
+
var cached = null;
|
|
2411
|
+
var inflight = null;
|
|
2412
|
+
function clearPulseToken() {
|
|
2413
|
+
cached = null;
|
|
2414
|
+
}
|
|
2415
|
+
async function getPulseToken(config, fetchImpl = fetch) {
|
|
2416
|
+
if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
|
|
2417
|
+
if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
|
|
2418
|
+
return cached.token;
|
|
2419
|
+
}
|
|
2420
|
+
if (inflight !== null) return inflight;
|
|
2421
|
+
const credentials = parsePulseAuth(config.pulseAuth);
|
|
2422
|
+
if (credentials === null) return null;
|
|
2423
|
+
inflight = (async () => {
|
|
1968
2424
|
try {
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
2425
|
+
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
2426
|
+
method: "POST",
|
|
2427
|
+
headers: {
|
|
2428
|
+
"Content-Type": "application/json",
|
|
2429
|
+
Accept: "application/json",
|
|
2430
|
+
"User-Agent": "@patchstack/connect"
|
|
2431
|
+
},
|
|
2432
|
+
body: JSON.stringify({
|
|
2433
|
+
grant_type: "client_credentials",
|
|
2434
|
+
client_id: credentials.clientId,
|
|
2435
|
+
client_secret: credentials.clientSecret
|
|
2436
|
+
}),
|
|
2437
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1980
2438
|
});
|
|
2439
|
+
if (!response.ok) return null;
|
|
2440
|
+
const body = await response.json();
|
|
2441
|
+
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
2442
|
+
const expiresIn = Number(body.expires_in);
|
|
2443
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
2444
|
+
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
2445
|
+
return body.access_token;
|
|
1981
2446
|
} catch {
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
|
|
1986
|
-
}
|
|
2447
|
+
return null;
|
|
2448
|
+
} finally {
|
|
2449
|
+
inflight = null;
|
|
1987
2450
|
}
|
|
2451
|
+
})();
|
|
2452
|
+
return inflight;
|
|
2453
|
+
}
|
|
2454
|
+
async function pulseAuthHeader(config, fetchImpl = fetch) {
|
|
2455
|
+
const token = await getPulseToken(config, fetchImpl);
|
|
2456
|
+
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
|
2457
|
+
}
|
|
2458
|
+
async function pulseFetch(config, url, init, fetchImpl = fetch) {
|
|
2459
|
+
const send = async () => {
|
|
2460
|
+
const auth = await pulseAuthHeader(config, fetchImpl);
|
|
2461
|
+
const response = await fetchImpl(url, {
|
|
2462
|
+
...init,
|
|
2463
|
+
headers: { ...init.headers, ...auth }
|
|
2464
|
+
});
|
|
2465
|
+
return { response, authenticated: auth.Authorization !== void 0 };
|
|
1988
2466
|
};
|
|
1989
|
-
|
|
2467
|
+
const first = await send();
|
|
2468
|
+
if (first.response.status === 401 && first.authenticated) {
|
|
2469
|
+
clearPulseToken();
|
|
2470
|
+
return (await send()).response;
|
|
2471
|
+
}
|
|
2472
|
+
return first.response;
|
|
1990
2473
|
}
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2474
|
+
|
|
2475
|
+
// src/protect/engine/pulse-client.js
|
|
2476
|
+
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
2477
|
+
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
2478
|
+
var JITTER_FRACTION2 = 0.1;
|
|
2479
|
+
var PulseRuleClient = class {
|
|
2480
|
+
#siteUuid;
|
|
2481
|
+
#timeoutMs;
|
|
2482
|
+
#baseUrl;
|
|
2483
|
+
#cacheTtl;
|
|
2484
|
+
#cache = null;
|
|
2485
|
+
#cacheTime = null;
|
|
2486
|
+
#ttlEffective = 0;
|
|
2487
|
+
#etag;
|
|
2488
|
+
#pulseAuth;
|
|
2489
|
+
#detectionState;
|
|
2490
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
|
|
2491
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
2492
|
+
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
2493
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
2494
|
+
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
2495
|
+
this.#etag = etag ?? null;
|
|
2496
|
+
this.#pulseAuth = pulseAuth ?? null;
|
|
2497
|
+
this.#detectionState = typeof detectionState === "string" ? detectionState : null;
|
|
2498
|
+
if (!this.#siteUuid) {
|
|
2499
|
+
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
2500
|
+
}
|
|
1995
2501
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2502
|
+
async getRules() {
|
|
2503
|
+
const now = Date.now();
|
|
2504
|
+
if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
|
|
2505
|
+
return this.#cache;
|
|
2506
|
+
}
|
|
2507
|
+
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
2508
|
+
try {
|
|
2509
|
+
const auth = await pulseAuthHeader(
|
|
2510
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
2511
|
+
fetch
|
|
2512
|
+
);
|
|
2513
|
+
const headers = { Accept: "application/json", ...auth };
|
|
2514
|
+
if (this.#detectionState !== null && typeof auth.Authorization === "string") {
|
|
2515
|
+
headers["X-Patchstack-Detections"] = this.#detectionState;
|
|
2516
|
+
}
|
|
2517
|
+
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
2518
|
+
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
2519
|
+
if (response.status === 304) {
|
|
2520
|
+
this.#touch(now);
|
|
2521
|
+
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
2522
|
+
}
|
|
2523
|
+
if (!response.ok) {
|
|
2524
|
+
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
2525
|
+
}
|
|
2526
|
+
const data = await response.json();
|
|
2527
|
+
if (!data || !Array.isArray(data.firewall)) {
|
|
2528
|
+
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
2529
|
+
}
|
|
2530
|
+
const result = {
|
|
2531
|
+
success: true,
|
|
2532
|
+
notModified: false,
|
|
2533
|
+
etag: response.headers?.get?.("etag") ?? null,
|
|
2534
|
+
firewall: data.firewall,
|
|
2535
|
+
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
2536
|
+
whitelist_keys: data.whitelist_keys ?? {},
|
|
2537
|
+
...enforcementField(data)
|
|
2538
|
+
};
|
|
2539
|
+
this.#cache = result;
|
|
2540
|
+
this.#etag = result.etag;
|
|
2541
|
+
this.#touch(now);
|
|
2542
|
+
return result;
|
|
2543
|
+
} catch (err) {
|
|
2544
|
+
return {
|
|
2545
|
+
success: false,
|
|
2546
|
+
error: err.name === "TimeoutError" ? "Request timed out" : err.message,
|
|
2547
|
+
firewall: [],
|
|
2548
|
+
whitelists: [],
|
|
2549
|
+
whitelist_keys: {}
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2002
2552
|
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2553
|
+
#touch(now) {
|
|
2554
|
+
this.#cacheTime = now;
|
|
2555
|
+
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
|
|
2556
|
+
}
|
|
2557
|
+
clearCache() {
|
|
2558
|
+
this.#cache = null;
|
|
2559
|
+
this.#cacheTime = null;
|
|
2560
|
+
this.#etag = null;
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
function enforcementField(data) {
|
|
2564
|
+
if (!data || typeof data !== "object") return {};
|
|
2565
|
+
const v = data.enforcement ?? data.mode;
|
|
2566
|
+
return v === "block" || v === "dry-run" ? { enforcement: v } : {};
|
|
2006
2567
|
}
|
|
2007
2568
|
|
|
2008
|
-
// src/protect/
|
|
2009
|
-
var
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
{
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
prefilter: ["AKIA", "ASIA"],
|
|
2026
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
2027
|
-
},
|
|
2028
|
-
{
|
|
2029
|
-
id: "resp-gcp-api-key",
|
|
2030
|
-
title: "Google API key in response body",
|
|
2031
|
-
phase: "response",
|
|
2032
|
-
category: "secret-exposure",
|
|
2033
|
-
action: "redact",
|
|
2034
|
-
prefilter: ["AIza"],
|
|
2035
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
2036
|
-
},
|
|
2037
|
-
{
|
|
2038
|
-
id: "resp-vendor-api-key",
|
|
2039
|
-
title: "Vendor API key / token in response body",
|
|
2040
|
-
phase: "response",
|
|
2041
|
-
category: "secret-exposure",
|
|
2042
|
-
action: "redact",
|
|
2043
|
-
// High-signal, prefix-anchored provider tokens that never legitimately appear in a response
|
|
2044
|
-
// body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
|
|
2045
|
-
// Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
|
|
2046
|
-
// (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
|
|
2047
|
-
prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
|
|
2048
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
|
|
2049
|
-
},
|
|
2050
|
-
{
|
|
2051
|
-
id: "resp-supabase-secret-key",
|
|
2052
|
-
title: "Supabase secret key in response body",
|
|
2053
|
-
phase: "response",
|
|
2054
|
-
category: "secret-exposure",
|
|
2055
|
-
action: "redact",
|
|
2056
|
-
// Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
|
|
2057
|
-
// `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
|
|
2058
|
-
// backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
|
|
2059
|
-
// Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
|
|
2060
|
-
// JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
|
|
2061
|
-
//
|
|
2062
|
-
// The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
|
|
2063
|
-
// base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
|
|
2064
|
-
// around it: a variable-length suffix would either run past the key into adjacent syntax or stop
|
|
2065
|
-
// short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
|
|
2066
|
-
// characters, which is not this format.
|
|
2067
|
-
prefilter: ["sb_secret_"],
|
|
2068
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
|
|
2069
|
-
},
|
|
2070
|
-
{
|
|
2071
|
-
id: "resp-supabase-service-role-key",
|
|
2072
|
-
title: "Supabase service_role key in response body",
|
|
2073
|
-
phase: "response",
|
|
2074
|
-
category: "secret-exposure",
|
|
2075
|
-
action: "redact",
|
|
2076
|
-
// Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
|
|
2077
|
-
// entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
|
|
2078
|
-
// by design and a login response carries the user's own access token — so this decides on the
|
|
2079
|
-
// decoded payload rather than on the shape of the token.
|
|
2080
|
-
//
|
|
2081
|
-
// `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
|
|
2082
|
-
// tokens and serve the rest of the response.
|
|
2083
|
-
//
|
|
2084
|
-
// A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
|
|
2085
|
-
// an opt-in, observe-only rule rather than an on-by-default mask.
|
|
2086
|
-
prefilter: ["eyJ"],
|
|
2087
|
-
rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
|
|
2088
|
-
},
|
|
2089
|
-
{
|
|
2090
|
-
id: "resp-db-connection-string",
|
|
2091
|
-
title: "Database connection string with credentials in response body",
|
|
2092
|
-
phase: "response",
|
|
2093
|
-
category: "secret-exposure",
|
|
2094
|
-
action: "redact",
|
|
2095
|
-
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
2096
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
2097
|
-
},
|
|
2098
|
-
{
|
|
2099
|
-
id: "resp-stack-trace",
|
|
2100
|
-
title: "Node stack trace leaking in response body",
|
|
2101
|
-
phase: "response",
|
|
2102
|
-
category: "info-exposure",
|
|
2103
|
-
action: "redact",
|
|
2104
|
-
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
2105
|
-
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
2106
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
2107
|
-
},
|
|
2108
|
-
{
|
|
2109
|
-
id: "resp-sql-error",
|
|
2110
|
-
title: "SQL / ORM error disclosure in response body",
|
|
2111
|
-
phase: "response",
|
|
2112
|
-
category: "info-exposure",
|
|
2113
|
-
action: "redact",
|
|
2114
|
-
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
2115
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
2116
|
-
},
|
|
2117
|
-
{
|
|
2118
|
-
id: "resp-exception-trace",
|
|
2119
|
-
title: "Backend exception / stack trace disclosure in response body",
|
|
2120
|
-
phase: "response",
|
|
2121
|
-
category: "info-exposure",
|
|
2122
|
-
action: "redact",
|
|
2123
|
-
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
2124
|
-
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
2125
|
-
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
2126
|
-
prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
|
|
2127
|
-
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }]
|
|
2569
|
+
// src/protect/engine/fetch.js
|
|
2570
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
2571
|
+
async function fromFetchRequest(request, options = {}) {
|
|
2572
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
2573
|
+
const url = new URL(request.url);
|
|
2574
|
+
const method = (request.method || "GET").toUpperCase();
|
|
2575
|
+
const headers = {};
|
|
2576
|
+
request.headers.forEach((value, key) => {
|
|
2577
|
+
headers[key.toLowerCase()] = value;
|
|
2578
|
+
});
|
|
2579
|
+
const query = {};
|
|
2580
|
+
for (const [key, value] of url.searchParams) {
|
|
2581
|
+
if (key in query) {
|
|
2582
|
+
query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
|
|
2583
|
+
} else {
|
|
2584
|
+
query[key] = value;
|
|
2585
|
+
}
|
|
2128
2586
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
id: "egress-internal-address",
|
|
2133
|
-
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
2134
|
-
phase: "egress",
|
|
2135
|
-
category: "ssrf",
|
|
2136
|
-
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
2587
|
+
let rawBody = "";
|
|
2588
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
2589
|
+
rawBody = await readCappedText(request, maxBodyBytes);
|
|
2137
2590
|
}
|
|
2138
|
-
];
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2591
|
+
const contentType = headers["content-type"] || "";
|
|
2592
|
+
let body = {};
|
|
2593
|
+
let files;
|
|
2594
|
+
if (rawBody) {
|
|
2595
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2596
|
+
body = parsed.body;
|
|
2597
|
+
files = parsed.files;
|
|
2598
|
+
}
|
|
2599
|
+
const uri = url.pathname + url.search;
|
|
2600
|
+
const client = resolveClientIp({ peer: options.peer, headers, trustedProxy: options.trustedProxy });
|
|
2601
|
+
return {
|
|
2602
|
+
method,
|
|
2603
|
+
url: uri,
|
|
2604
|
+
originalUrl: uri,
|
|
2605
|
+
query,
|
|
2606
|
+
body,
|
|
2607
|
+
files,
|
|
2608
|
+
headers,
|
|
2609
|
+
// The resolved address, as an own property. Nothing downstream re-derives or falls back: a consumer
|
|
2610
|
+
// that guessed differently would attribute one request to two addresses.
|
|
2611
|
+
ip: client.ip ?? "",
|
|
2612
|
+
_clientIp: client,
|
|
2613
|
+
cookies: parseCookies(headers.cookie),
|
|
2614
|
+
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
2615
|
+
// drops, so prototype-pollution rules on `raw` are robust.
|
|
2616
|
+
_rawBody: rawBody
|
|
2617
|
+
};
|
|
2143
2618
|
}
|
|
2144
|
-
function
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
</div>
|
|
2168
|
-
<div class="site-url">
|
|
2169
|
-
<input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
|
|
2170
|
-
<img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='response')">
|
|
2171
|
-
</div>
|
|
2172
|
-
<div class="descp">
|
|
2173
|
-
<span class="descp-text">This request has been blocked by Patchstack. If this message persists and you are a legitimate user, contact the site administrator with the reference above.</span>
|
|
2174
|
-
</div>
|
|
2175
|
-
</div>
|
|
2176
|
-
</div>
|
|
2177
|
-
<div class="bottom-container">
|
|
2178
|
-
<div class="bottom-text-containers">
|
|
2179
|
-
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
2180
|
-
<a href="/" class="return-text">Return to homepage</a>
|
|
2181
|
-
</div>
|
|
2182
|
-
<div class="bottom-second-containers">
|
|
2183
|
-
<span class="secured-text">This website is secured by</span>
|
|
2184
|
-
<a href="https://patchstack.com" class="logoText">
|
|
2185
|
-
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
2186
|
-
</a>
|
|
2187
|
-
</div>
|
|
2188
|
-
</div>
|
|
2189
|
-
|
|
2190
|
-
<div class="mobile-bottom-container">
|
|
2191
|
-
<div class="mobile-bottom-text-containers">
|
|
2192
|
-
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
2193
|
-
<a href="/" class="return-text">Return to homepage</a>
|
|
2194
|
-
</div>
|
|
2195
|
-
<div class="mobile-bottom-second-containers">
|
|
2196
|
-
<span class="secured-text">This website is secured by</span>
|
|
2197
|
-
<a href="https://patchstack.com" class="logoText">
|
|
2198
|
-
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
2199
|
-
</a>
|
|
2200
|
-
</div>
|
|
2201
|
-
</div>
|
|
2202
|
-
</div>
|
|
2203
|
-
</body>
|
|
2204
|
-
<script>
|
|
2205
|
-
function copyText(id) {
|
|
2206
|
-
var copyText = document.getElementById(id)
|
|
2207
|
-
copyText.select();
|
|
2208
|
-
copyText.setSelectionRange(0, 99999);
|
|
2209
|
-
navigator.clipboard.writeText(copyText.value);
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
function hover(element) {
|
|
2213
|
-
element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
|
|
2619
|
+
async function readCappedText(request, max) {
|
|
2620
|
+
let clone;
|
|
2621
|
+
try {
|
|
2622
|
+
clone = request.clone();
|
|
2623
|
+
} catch {
|
|
2624
|
+
return "";
|
|
2625
|
+
}
|
|
2626
|
+
const body = clone.body;
|
|
2627
|
+
if (body && typeof body.getReader === "function") {
|
|
2628
|
+
const reader = body.getReader();
|
|
2629
|
+
const chunks = [];
|
|
2630
|
+
let buffered = 0;
|
|
2631
|
+
try {
|
|
2632
|
+
for (; ; ) {
|
|
2633
|
+
const { done, value } = await reader.read();
|
|
2634
|
+
if (done) break;
|
|
2635
|
+
if (!value || buffered >= max) continue;
|
|
2636
|
+
const take = Math.min(value.byteLength, max - buffered);
|
|
2637
|
+
chunks.push(take === value.byteLength ? value : value.subarray(0, take));
|
|
2638
|
+
buffered += take;
|
|
2639
|
+
}
|
|
2640
|
+
} catch {
|
|
2641
|
+
return "";
|
|
2214
2642
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2643
|
+
try {
|
|
2644
|
+
return new TextDecoder().decode(concatChunks(chunks, buffered));
|
|
2645
|
+
} catch {
|
|
2646
|
+
return "";
|
|
2218
2647
|
}
|
|
2219
|
-
|
|
2220
|
-
</html>`;
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
// src/protect/rules/store.js
|
|
2224
|
-
var fsMod;
|
|
2225
|
-
async function loadFs() {
|
|
2226
|
-
if (fsMod !== void 0) return fsMod;
|
|
2648
|
+
}
|
|
2227
2649
|
try {
|
|
2228
|
-
const
|
|
2229
|
-
|
|
2230
|
-
readFileSync: fs.readFileSync,
|
|
2231
|
-
writeFileSync: fs.writeFileSync,
|
|
2232
|
-
mkdirSync: fs.mkdirSync,
|
|
2233
|
-
join: path.join
|
|
2234
|
-
};
|
|
2650
|
+
const text = await clone.text();
|
|
2651
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
2235
2652
|
} catch {
|
|
2236
|
-
|
|
2653
|
+
return "";
|
|
2237
2654
|
}
|
|
2238
|
-
return fsMod;
|
|
2239
2655
|
}
|
|
2240
|
-
function
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
const env = await durable.read();
|
|
2247
|
-
if (env) mem = env;
|
|
2248
|
-
return env;
|
|
2249
|
-
},
|
|
2250
|
-
async write(env) {
|
|
2251
|
-
if (env) mem = env;
|
|
2252
|
-
await durable.write(env);
|
|
2253
|
-
}
|
|
2254
|
-
};
|
|
2255
|
-
}
|
|
2256
|
-
function durableTier(options) {
|
|
2257
|
-
const adapter = options.ruleCache;
|
|
2258
|
-
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
2259
|
-
return {
|
|
2260
|
-
read: async () => {
|
|
2261
|
-
try {
|
|
2262
|
-
return toEnvelope(await adapter.read());
|
|
2263
|
-
} catch {
|
|
2264
|
-
return null;
|
|
2265
|
-
}
|
|
2266
|
-
},
|
|
2267
|
-
write: async (env) => {
|
|
2268
|
-
try {
|
|
2269
|
-
await adapter.write(env);
|
|
2270
|
-
} catch {
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
2275
|
-
const dir = options.cacheDir;
|
|
2276
|
-
return {
|
|
2277
|
-
read: async () => cacheRead(dir),
|
|
2278
|
-
write: async (env) => cacheWrite(dir, env)
|
|
2279
|
-
};
|
|
2280
|
-
}
|
|
2281
|
-
async function cacheWrite(dir, env) {
|
|
2282
|
-
if (!dir) return;
|
|
2283
|
-
const fs = await loadFs();
|
|
2284
|
-
if (!fs) return;
|
|
2285
|
-
try {
|
|
2286
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
2287
|
-
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
2288
|
-
} catch {
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
async function cacheRead(dir) {
|
|
2292
|
-
if (!dir) return null;
|
|
2293
|
-
const fs = await loadFs();
|
|
2294
|
-
if (!fs) return null;
|
|
2295
|
-
try {
|
|
2296
|
-
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
2297
|
-
} catch {
|
|
2298
|
-
return null;
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
function toEnvelope(value) {
|
|
2302
|
-
if (!value || typeof value !== "object") return null;
|
|
2303
|
-
if (value.bundle && typeof value.bundle === "object") {
|
|
2304
|
-
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
2305
|
-
}
|
|
2306
|
-
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
2307
|
-
return { bundle: value, etag: null };
|
|
2308
|
-
}
|
|
2309
|
-
return null;
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
// src/protect/rules/contract.js
|
|
2313
|
-
var SOURCES = Object.freeze({
|
|
2314
|
-
raw: Object.freeze({ keyed: false }),
|
|
2315
|
-
all: Object.freeze({ keyed: false }),
|
|
2316
|
-
false: Object.freeze({ keyed: false }),
|
|
2317
|
-
rules: Object.freeze({ keyed: false, group: true }),
|
|
2318
|
-
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2319
|
-
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2320
|
-
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2321
|
-
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2322
|
-
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
2323
|
-
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
2324
|
-
files: Object.freeze({
|
|
2325
|
-
keyed: true,
|
|
2326
|
-
keys: "any",
|
|
2327
|
-
wildcard: true,
|
|
2328
|
-
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
2329
|
-
}),
|
|
2330
|
-
server: Object.freeze({
|
|
2331
|
-
keyed: true,
|
|
2332
|
-
keys: Object.freeze([
|
|
2333
|
-
"REQUEST_URI",
|
|
2334
|
-
"REQUEST_METHOD",
|
|
2335
|
-
"HTTP_USER_AGENT",
|
|
2336
|
-
"HTTP_REFERER",
|
|
2337
|
-
"HTTP_HOST",
|
|
2338
|
-
"REMOTE_ADDR",
|
|
2339
|
-
"ip",
|
|
2340
|
-
"CONTENT_TYPE",
|
|
2341
|
-
"CONTENT_LENGTH"
|
|
2342
|
-
]),
|
|
2343
|
-
key_prefixes: Object.freeze(["HTTP_"])
|
|
2344
|
-
}),
|
|
2345
|
-
response: Object.freeze({
|
|
2346
|
-
keyed: true,
|
|
2347
|
-
keys: Object.freeze(["status", "body", "headers"]),
|
|
2348
|
-
key_prefixes: Object.freeze(["header."])
|
|
2349
|
-
}),
|
|
2350
|
-
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
2351
|
-
});
|
|
2352
|
-
var MATCH_TYPES = Object.freeze([
|
|
2353
|
-
"array_in_array",
|
|
2354
|
-
"array_key_value",
|
|
2355
|
-
"contains",
|
|
2356
|
-
"cors_reflected",
|
|
2357
|
-
"cross_origin",
|
|
2358
|
-
"ctype_alnum",
|
|
2359
|
-
"ctype_digit",
|
|
2360
|
-
"ctype_special",
|
|
2361
|
-
"equals",
|
|
2362
|
-
"equals_strict",
|
|
2363
|
-
"hostname",
|
|
2364
|
-
"in_array",
|
|
2365
|
-
"inline_js_xss",
|
|
2366
|
-
"inline_xss",
|
|
2367
|
-
"internal_host",
|
|
2368
|
-
"is_numeric",
|
|
2369
|
-
"isset",
|
|
2370
|
-
"jwt_claim_equals",
|
|
2371
|
-
"less_than",
|
|
2372
|
-
"more_than",
|
|
2373
|
-
"not_contains",
|
|
2374
|
-
"not_in_array",
|
|
2375
|
-
"off_origin",
|
|
2376
|
-
"quotes",
|
|
2377
|
-
"regex",
|
|
2378
|
-
"stripos"
|
|
2379
|
-
]);
|
|
2380
|
-
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
2381
|
-
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
2382
|
-
"isset",
|
|
2383
|
-
"quotes",
|
|
2384
|
-
"is_numeric",
|
|
2385
|
-
"ctype_alnum",
|
|
2386
|
-
"ctype_digit",
|
|
2387
|
-
"ctype_special",
|
|
2388
|
-
"inline_xss",
|
|
2389
|
-
"inline_js_xss",
|
|
2390
|
-
"internal_host",
|
|
2391
|
-
"cross_origin",
|
|
2392
|
-
"off_origin",
|
|
2393
|
-
"cors_reflected"
|
|
2394
|
-
]);
|
|
2395
|
-
var OPERAND_SHAPES = Object.freeze([
|
|
2396
|
-
"positive-number",
|
|
2397
|
-
"boolean",
|
|
2398
|
-
"non-empty-string",
|
|
2399
|
-
"regex-literal",
|
|
2400
|
-
"string-or-string-list",
|
|
2401
|
-
"scalar",
|
|
2402
|
-
"string",
|
|
2403
|
-
"numeric",
|
|
2404
|
-
"scalar-or-non-empty-list",
|
|
2405
|
-
"non-empty-list",
|
|
2406
|
-
"object",
|
|
2407
|
-
"non-empty-object",
|
|
2408
|
-
"non-empty-string-list",
|
|
2409
|
-
"header-name-map",
|
|
2410
|
-
"header-name-list",
|
|
2411
|
-
"cookie-flags"
|
|
2412
|
-
]);
|
|
2413
|
-
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
2414
|
-
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
2415
|
-
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2416
|
-
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2417
|
-
var MATCH_OPERANDS = Object.freeze({
|
|
2418
|
-
array_key_value: Object.freeze({
|
|
2419
|
-
required: Object.freeze(["key", "match"]),
|
|
2420
|
-
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
2421
|
-
}),
|
|
2422
|
-
// Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
|
|
2423
|
-
// `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
|
|
2424
|
-
// exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
|
|
2425
|
-
// oversized, claim missing, claim not a string, or a different value — is a no-match.
|
|
2426
|
-
jwt_claim_equals: Object.freeze({
|
|
2427
|
-
required: Object.freeze(["claim", "value"]),
|
|
2428
|
-
shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
|
|
2429
|
-
}),
|
|
2430
|
-
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
2431
|
-
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2432
|
-
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2433
|
-
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2434
|
-
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
2435
|
-
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
2436
|
-
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
2437
|
-
// `array_in_array` inert and `not_in_array` true for every value.
|
|
2438
|
-
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2439
|
-
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2440
|
-
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2441
|
-
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
2442
|
-
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
2443
|
-
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
2444
|
-
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
2445
|
-
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
2446
|
-
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
2447
|
-
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
2448
|
-
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
2449
|
-
});
|
|
2450
|
-
function operandShapeProblem(shape, value) {
|
|
2451
|
-
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
2452
|
-
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
2453
|
-
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
2454
|
-
switch (shape) {
|
|
2455
|
-
case "positive-number":
|
|
2456
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
|
|
2457
|
-
case "boolean":
|
|
2458
|
-
return typeof value === "boolean" ? null : "must be true or false";
|
|
2459
|
-
case "non-empty-string":
|
|
2460
|
-
return isFilledString(value) ? null : "must be a non-empty string";
|
|
2461
|
-
case "regex-literal":
|
|
2462
|
-
if (!isFilledString(value)) return "must be a non-empty string";
|
|
2463
|
-
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
2464
|
-
case "string-or-string-list":
|
|
2465
|
-
if (isFilledString(value)) return null;
|
|
2466
|
-
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
2467
|
-
return "must be a non-empty string, or a non-empty list of them";
|
|
2468
|
-
case "scalar":
|
|
2469
|
-
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
2470
|
-
case "string":
|
|
2471
|
-
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
2472
|
-
case "numeric":
|
|
2473
|
-
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
2474
|
-
case "scalar-or-non-empty-list":
|
|
2475
|
-
if (isScalar(value)) return null;
|
|
2476
|
-
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
2477
|
-
return "must be a scalar, or a non-empty list of them";
|
|
2478
|
-
case "non-empty-list":
|
|
2479
|
-
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
2480
|
-
case "header-name-map": {
|
|
2481
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2482
|
-
const names = Object.keys(value);
|
|
2483
|
-
if (names.length === 0) return "must be an object with at least one entry";
|
|
2484
|
-
const bad = names.find((name) => !isHeaderName(name));
|
|
2485
|
-
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2486
|
-
}
|
|
2487
|
-
case "header-name-list": {
|
|
2488
|
-
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
2489
|
-
const bad = value.find((name) => !isHeaderName(name));
|
|
2490
|
-
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2491
|
-
}
|
|
2492
|
-
case "cookie-flags": {
|
|
2493
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2494
|
-
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
2495
|
-
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
2496
|
-
for (const flag of ["httpOnly", "secure"]) {
|
|
2497
|
-
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
2498
|
-
return `flag "${flag}" must be true or false`;
|
|
2499
|
-
}
|
|
2500
|
-
}
|
|
2501
|
-
const sameSite = value.sameSite;
|
|
2502
|
-
const sameSiteOff = sameSite === false;
|
|
2503
|
-
if (sameSite !== void 0 && !sameSiteOff) {
|
|
2504
|
-
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
2505
|
-
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
2509
|
-
return "turns off every flag, so it would harden nothing";
|
|
2510
|
-
}
|
|
2511
|
-
return null;
|
|
2512
|
-
}
|
|
2513
|
-
case "object":
|
|
2514
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
2515
|
-
case "non-empty-object":
|
|
2516
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
2517
|
-
case "non-empty-string-list":
|
|
2518
|
-
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
2519
|
-
default:
|
|
2520
|
-
return null;
|
|
2656
|
+
function concatChunks(chunks, total) {
|
|
2657
|
+
const out = new Uint8Array(total);
|
|
2658
|
+
let offset = 0;
|
|
2659
|
+
for (const c of chunks) {
|
|
2660
|
+
out.set(c, offset);
|
|
2661
|
+
offset += c.byteLength;
|
|
2521
2662
|
}
|
|
2663
|
+
return out;
|
|
2522
2664
|
}
|
|
2523
|
-
|
|
2524
|
-
"
|
|
2525
|
-
"
|
|
2526
|
-
"
|
|
2527
|
-
"
|
|
2528
|
-
"
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
}
|
|
2547
|
-
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
2548
|
-
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
2549
|
-
var RULE_PROPERTIES = Object.freeze([
|
|
2550
|
-
"id",
|
|
2551
|
-
"rule_id",
|
|
2552
|
-
"title",
|
|
2553
|
-
"category",
|
|
2554
|
-
"phase",
|
|
2555
|
-
"action",
|
|
2556
|
-
"rule_v2",
|
|
2557
|
-
"when",
|
|
2558
|
-
"message",
|
|
2559
|
-
"enforcement",
|
|
2560
|
-
"source_revision",
|
|
2561
|
-
"prefilter",
|
|
2562
|
-
"max_bytes",
|
|
2563
|
-
"bypass_limit",
|
|
2564
|
-
"set_headers",
|
|
2565
|
-
"remove_headers",
|
|
2566
|
-
"cookie_flags",
|
|
2567
|
-
"ensure"
|
|
2568
|
-
]);
|
|
2569
|
-
var ACTION_PROPERTIES = Object.freeze({
|
|
2570
|
-
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
2571
|
-
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2572
|
-
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2573
|
-
"set-header": Object.freeze({
|
|
2574
|
-
required: Object.freeze(["set_headers"]),
|
|
2575
|
-
defaulted: Object.freeze(["ensure"]),
|
|
2576
|
-
phases: Object.freeze(["response"]),
|
|
2577
|
-
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
2578
|
-
}),
|
|
2579
|
-
"remove-header": Object.freeze({
|
|
2580
|
-
required: Object.freeze(["remove_headers"]),
|
|
2581
|
-
defaulted: Object.freeze([]),
|
|
2582
|
-
phases: Object.freeze(["response"]),
|
|
2583
|
-
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
2584
|
-
}),
|
|
2585
|
-
"harden-cookie": Object.freeze({
|
|
2586
|
-
required: Object.freeze([]),
|
|
2587
|
-
defaulted: Object.freeze(["cookie_flags"]),
|
|
2588
|
-
phases: Object.freeze(["response"]),
|
|
2589
|
-
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
2590
|
-
})
|
|
2591
|
-
});
|
|
2592
|
-
var LIMITS = Object.freeze({
|
|
2593
|
-
maxRules: 5e3,
|
|
2594
|
-
maxWhitelists: 2e3,
|
|
2595
|
-
maxConditionsPerRule: 250,
|
|
2596
|
-
maxNestingDepth: 12,
|
|
2597
|
-
maxRegexLength: 1e3,
|
|
2598
|
-
maxValueLength: 8192
|
|
2599
|
-
});
|
|
2600
|
-
var GROUP_PARAMETER = "rules";
|
|
2601
|
-
function isGroup(condition) {
|
|
2602
|
-
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
2665
|
+
function parseBody(rawBody, contentType) {
|
|
2666
|
+
const ct = String(contentType || "").toLowerCase();
|
|
2667
|
+
const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
|
|
2668
|
+
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
2669
|
+
const isMultipart = ct.includes("multipart/form-data");
|
|
2670
|
+
const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
|
|
2671
|
+
if (isMultipart) {
|
|
2672
|
+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2673
|
+
if (boundary) return parseMultipart(rawBody, boundary);
|
|
2674
|
+
return { body: {}, files: void 0 };
|
|
2675
|
+
}
|
|
2676
|
+
if (isForm) {
|
|
2677
|
+
const body = {};
|
|
2678
|
+
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2679
|
+
return { body, files: void 0 };
|
|
2680
|
+
}
|
|
2681
|
+
if (isJson || isAmbiguous) {
|
|
2682
|
+
try {
|
|
2683
|
+
const parsed = JSON.parse(rawBody);
|
|
2684
|
+
if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
|
|
2685
|
+
} catch {
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
return { body: {}, files: void 0 };
|
|
2603
2689
|
}
|
|
2604
|
-
function
|
|
2605
|
-
|
|
2606
|
-
const
|
|
2607
|
-
const
|
|
2608
|
-
|
|
2609
|
-
|
|
2690
|
+
function parseMultipart(rawBody, boundary) {
|
|
2691
|
+
const body = {};
|
|
2692
|
+
const files = {};
|
|
2693
|
+
for (const part of rawBody.split("--" + boundary)) {
|
|
2694
|
+
const sep = /\r?\n\r?\n/.exec(part);
|
|
2695
|
+
if (!sep) continue;
|
|
2696
|
+
const rawHeaders = part.slice(0, sep.index);
|
|
2697
|
+
const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
|
|
2698
|
+
if (!disposition) continue;
|
|
2699
|
+
const name = /name="([^"]*)"/i.exec(disposition)?.[1];
|
|
2700
|
+
if (name == null) continue;
|
|
2701
|
+
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
2702
|
+
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
2703
|
+
if (filename !== void 0) {
|
|
2704
|
+
const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
|
|
2705
|
+
const file = { filename, type: partType, content };
|
|
2706
|
+
files[name] = name in files ? [].concat(files[name], file) : file;
|
|
2707
|
+
} else {
|
|
2708
|
+
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
2709
|
+
}
|
|
2610
2710
|
}
|
|
2611
|
-
|
|
2612
|
-
|
|
2711
|
+
return { body, files };
|
|
2712
|
+
}
|
|
2713
|
+
function parseCookies(header) {
|
|
2714
|
+
const cookies = {};
|
|
2715
|
+
if (!header) {
|
|
2716
|
+
return cookies;
|
|
2613
2717
|
}
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
if (
|
|
2617
|
-
|
|
2718
|
+
for (const pair of header.split(";")) {
|
|
2719
|
+
const idx = pair.indexOf("=");
|
|
2720
|
+
if (idx === -1) {
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
2618
2724
|
}
|
|
2619
|
-
return
|
|
2725
|
+
return cookies;
|
|
2620
2726
|
}
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2727
|
+
|
|
2728
|
+
// src/protect/engine/node.js
|
|
2729
|
+
function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
2730
|
+
const method = (req.method || "GET").toUpperCase();
|
|
2731
|
+
const headers = {};
|
|
2732
|
+
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
2733
|
+
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
2734
|
+
}
|
|
2735
|
+
const host = headers.host || "localhost";
|
|
2736
|
+
let url;
|
|
2737
|
+
try {
|
|
2738
|
+
url = new URL(req.url || "/", `http://${host}`);
|
|
2739
|
+
} catch {
|
|
2740
|
+
try {
|
|
2741
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
2742
|
+
} catch {
|
|
2743
|
+
url = new URL("http://localhost/");
|
|
2628
2744
|
}
|
|
2629
|
-
return null;
|
|
2630
2745
|
}
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2746
|
+
const query = {};
|
|
2747
|
+
for (const [key, value] of url.searchParams) {
|
|
2748
|
+
if (key in query) {
|
|
2749
|
+
query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
|
|
2750
|
+
} else {
|
|
2751
|
+
query[key] = value;
|
|
2752
|
+
}
|
|
2634
2753
|
}
|
|
2635
|
-
const
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
if (
|
|
2639
|
-
|
|
2640
|
-
|
|
2754
|
+
const contentType = headers["content-type"] || "";
|
|
2755
|
+
let body = {};
|
|
2756
|
+
let files;
|
|
2757
|
+
if (rawBody) {
|
|
2758
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2759
|
+
body = parsed.body;
|
|
2760
|
+
files = parsed.files;
|
|
2641
2761
|
}
|
|
2642
|
-
|
|
2643
|
-
const
|
|
2644
|
-
|
|
2645
|
-
|
|
2762
|
+
const uri = url.pathname + url.search;
|
|
2763
|
+
const client = resolveClientIp({
|
|
2764
|
+
peer: req.socket?.remoteAddress,
|
|
2765
|
+
headers,
|
|
2766
|
+
trustedProxy: options.trustedProxy
|
|
2767
|
+
});
|
|
2768
|
+
return {
|
|
2769
|
+
method,
|
|
2770
|
+
url: uri,
|
|
2771
|
+
originalUrl: uri,
|
|
2772
|
+
query,
|
|
2773
|
+
body,
|
|
2774
|
+
files,
|
|
2775
|
+
headers,
|
|
2776
|
+
ip: client.ip ?? "",
|
|
2777
|
+
_clientIp: client,
|
|
2778
|
+
cookies: parseCookies2(headers.cookie),
|
|
2779
|
+
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
2780
|
+
_rawBody: rawBody
|
|
2781
|
+
};
|
|
2646
2782
|
}
|
|
2647
|
-
function
|
|
2648
|
-
|
|
2649
|
-
|
|
2783
|
+
function parseCookies2(header) {
|
|
2784
|
+
const cookies = {};
|
|
2785
|
+
if (!header) {
|
|
2786
|
+
return cookies;
|
|
2650
2787
|
}
|
|
2651
|
-
const
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2788
|
+
for (const pair of header.split(";")) {
|
|
2789
|
+
const idx = pair.indexOf("=");
|
|
2790
|
+
if (idx === -1) {
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
2656
2794
|
}
|
|
2657
|
-
|
|
2658
|
-
...Array.isArray(source.keys) ? source.keys : [],
|
|
2659
|
-
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2660
|
-
].join(", ");
|
|
2661
|
-
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2795
|
+
return cookies;
|
|
2662
2796
|
}
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2797
|
+
|
|
2798
|
+
// src/protect/egress.js
|
|
2799
|
+
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
2800
|
+
const restores = [];
|
|
2801
|
+
if (typeof shouldBlock !== "function") return () => {
|
|
2802
|
+
};
|
|
2803
|
+
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
2804
|
+
const block = (url, host, method) => {
|
|
2805
|
+
if (!shouldBlock(url, host, method)) return false;
|
|
2806
|
+
notify(onBlock, { url, host, method }, "onEgressBlock");
|
|
2807
|
+
return true;
|
|
2808
|
+
};
|
|
2809
|
+
let screen = null;
|
|
2810
|
+
if (dnsScreen) {
|
|
2811
|
+
try {
|
|
2812
|
+
const resolveLookup = lookup ?? (await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))).lookup;
|
|
2813
|
+
const { isIP } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
|
|
2814
|
+
if (typeof resolveLookup === "function" && typeof isIP === "function") {
|
|
2815
|
+
screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
|
|
2816
|
+
}
|
|
2817
|
+
} catch {
|
|
2818
|
+
screen = null;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
|
|
2822
|
+
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
2823
|
+
if (!screen) {
|
|
2824
|
+
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
2825
|
+
return resolve(false);
|
|
2826
|
+
}
|
|
2827
|
+
if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
|
|
2828
|
+
try {
|
|
2829
|
+
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
2830
|
+
if (err || !Array.isArray(addresses)) {
|
|
2831
|
+
skip("resolver-failed", { host });
|
|
2832
|
+
return resolve(false);
|
|
2833
|
+
}
|
|
2834
|
+
for (const a of addresses) {
|
|
2835
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
2836
|
+
if (ip && block(url, ip, method)) return resolve(true);
|
|
2837
|
+
}
|
|
2838
|
+
resolve(false);
|
|
2839
|
+
});
|
|
2840
|
+
} catch {
|
|
2841
|
+
skip("resolver-failed", { host });
|
|
2842
|
+
resolve(false);
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
const originalFetch = globalThis.fetch;
|
|
2846
|
+
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
2847
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
2848
|
+
const MAX_REDIRECTS = 20;
|
|
2849
|
+
const screenUrl = async (u, method) => {
|
|
2850
|
+
let host = null;
|
|
2851
|
+
try {
|
|
2852
|
+
host = new URL(u).hostname;
|
|
2853
|
+
} catch {
|
|
2854
|
+
host = null;
|
|
2855
|
+
}
|
|
2856
|
+
if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
|
|
2857
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
const guarded = async (input, init) => {
|
|
2861
|
+
let cur;
|
|
2862
|
+
try {
|
|
2863
|
+
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
2864
|
+
} catch {
|
|
2865
|
+
return originalFetch(input, init);
|
|
2866
|
+
}
|
|
2867
|
+
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
2868
|
+
let url = cur.url;
|
|
2869
|
+
let method = cur.method;
|
|
2870
|
+
await screenUrl(url, method);
|
|
2871
|
+
if (callerRedirect !== "follow") return originalFetch(input, init);
|
|
2872
|
+
const headers = new Headers(cur.headers);
|
|
2873
|
+
const signal = cur.signal;
|
|
2874
|
+
let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
|
|
2875
|
+
for (let hop = 0; ; hop++) {
|
|
2876
|
+
const resp = await originalFetch(
|
|
2877
|
+
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
2878
|
+
);
|
|
2879
|
+
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
2880
|
+
if (!location) return resp;
|
|
2881
|
+
if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
|
|
2882
|
+
const next = new URL(location, url).href;
|
|
2883
|
+
if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
|
|
2884
|
+
method = "GET";
|
|
2885
|
+
body = void 0;
|
|
2886
|
+
headers.delete("content-type");
|
|
2887
|
+
headers.delete("content-length");
|
|
2888
|
+
}
|
|
2889
|
+
if (new URL(next).origin !== new URL(url).origin) {
|
|
2890
|
+
headers.delete("authorization");
|
|
2891
|
+
headers.delete("cookie");
|
|
2892
|
+
}
|
|
2893
|
+
await screenUrl(next, method);
|
|
2894
|
+
url = next;
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
guarded.__patchstackGuarded = true;
|
|
2898
|
+
globalThis.fetch = guarded;
|
|
2899
|
+
restores.push(() => {
|
|
2900
|
+
if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
|
|
2901
|
+
});
|
|
2670
2902
|
}
|
|
2671
|
-
|
|
2672
|
-
|
|
2903
|
+
for (const moduleName of ["node:http", "node:https"]) {
|
|
2904
|
+
try {
|
|
2905
|
+
const mod = await import(moduleName);
|
|
2906
|
+
const restore = patchHttpModule(mod.default ?? mod, block, screen);
|
|
2907
|
+
if (restore) restores.push(restore);
|
|
2908
|
+
} catch {
|
|
2909
|
+
}
|
|
2673
2910
|
}
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2911
|
+
return () => {
|
|
2912
|
+
for (const restore of restores) {
|
|
2913
|
+
try {
|
|
2914
|
+
restore();
|
|
2915
|
+
} catch {
|
|
2679
2916
|
}
|
|
2680
2917
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
function patchHttpModule(http, block, screen) {
|
|
2921
|
+
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
2922
|
+
const originalRequest = http.request;
|
|
2923
|
+
const originalGet = http.get;
|
|
2924
|
+
const wrap = (original) => function(...args) {
|
|
2925
|
+
const target = extractHttpTarget(args);
|
|
2926
|
+
if (target && block(target.url, target.host, target.method)) {
|
|
2927
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
|
|
2685
2928
|
}
|
|
2686
|
-
|
|
2687
|
-
|
|
2929
|
+
if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
|
|
2930
|
+
try {
|
|
2931
|
+
args = withScreeningLookup(args, target, block, screen.lookup);
|
|
2932
|
+
} catch {
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return original.apply(this, args);
|
|
2936
|
+
};
|
|
2937
|
+
const guardedRequest = wrap(originalRequest);
|
|
2938
|
+
http.request = guardedRequest;
|
|
2939
|
+
let guardedGet;
|
|
2940
|
+
if (typeof originalGet === "function") {
|
|
2941
|
+
guardedGet = wrap(originalGet);
|
|
2942
|
+
http.get = guardedGet;
|
|
2688
2943
|
}
|
|
2689
|
-
|
|
2944
|
+
http.__patchstackGuarded = true;
|
|
2945
|
+
return () => {
|
|
2946
|
+
if (http.request === guardedRequest) http.request = originalRequest;
|
|
2947
|
+
if (guardedGet && http.get === guardedGet) http.get = originalGet;
|
|
2948
|
+
delete http.__patchstackGuarded;
|
|
2949
|
+
};
|
|
2690
2950
|
}
|
|
2691
|
-
function
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2951
|
+
function extractHttpTarget(args) {
|
|
2952
|
+
const first = args[0];
|
|
2953
|
+
try {
|
|
2954
|
+
if (typeof first === "string" || first instanceof URL) {
|
|
2955
|
+
const url = new URL(String(first));
|
|
2956
|
+
const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
2957
|
+
return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
|
|
2958
|
+
}
|
|
2959
|
+
if (first && typeof first === "object") {
|
|
2960
|
+
const host = normalizeHost(first.hostname || first.host) || "localhost";
|
|
2961
|
+
const protocol = first.protocol || "http:";
|
|
2962
|
+
const port = first.port ? `:${first.port}` : "";
|
|
2963
|
+
const path = first.path || "/";
|
|
2964
|
+
return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
|
|
2697
2965
|
}
|
|
2966
|
+
} catch {
|
|
2698
2967
|
}
|
|
2699
2968
|
return null;
|
|
2700
2969
|
}
|
|
2701
|
-
function
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
if (
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2970
|
+
function normalizeHost(raw) {
|
|
2971
|
+
const host = String(raw || "").trim();
|
|
2972
|
+
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
|
2973
|
+
if (bracketed) return bracketed[1];
|
|
2974
|
+
if ((host.match(/:/g) || []).length > 1) return host;
|
|
2975
|
+
return host.split(":")[0];
|
|
2976
|
+
}
|
|
2977
|
+
function screenResolved(addresses, target, block) {
|
|
2978
|
+
for (const a of addresses || []) {
|
|
2979
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
2980
|
+
if (ip && block(target.url, ip, target.method)) return ip;
|
|
2709
2981
|
}
|
|
2710
|
-
|
|
2711
|
-
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
function withScreeningLookup(args, target, block, lookup) {
|
|
2985
|
+
const screeningLookup = (hostname, options, callback) => {
|
|
2986
|
+
let opts = options;
|
|
2987
|
+
let cb = callback;
|
|
2988
|
+
if (typeof opts === "function") {
|
|
2989
|
+
cb = opts;
|
|
2990
|
+
opts = {};
|
|
2991
|
+
}
|
|
2992
|
+
if (!opts || typeof opts !== "object") opts = {};
|
|
2993
|
+
try {
|
|
2994
|
+
lookup(hostname, { ...opts, all: true }, (err, addresses) => {
|
|
2995
|
+
if (err) return cb(err);
|
|
2996
|
+
const list = Array.isArray(addresses) ? addresses : [];
|
|
2997
|
+
const blocked = screenResolved(list, target, block);
|
|
2998
|
+
if (blocked) {
|
|
2999
|
+
return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
|
|
3000
|
+
}
|
|
3001
|
+
if (opts.all) return cb(null, list);
|
|
3002
|
+
const first = list[0];
|
|
3003
|
+
if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
|
|
3004
|
+
return cb(null, first.address, first.family);
|
|
3005
|
+
});
|
|
3006
|
+
} catch {
|
|
3007
|
+
try {
|
|
3008
|
+
lookup(hostname, opts, cb);
|
|
3009
|
+
} catch {
|
|
3010
|
+
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
};
|
|
3014
|
+
return injectLookupOption(args, screeningLookup);
|
|
3015
|
+
}
|
|
3016
|
+
function injectLookupOption(args, lookup) {
|
|
3017
|
+
const first = args[0];
|
|
3018
|
+
if (first && typeof first === "object" && !(first instanceof URL)) {
|
|
3019
|
+
return [{ ...first, lookup }, ...args.slice(1)];
|
|
3020
|
+
}
|
|
3021
|
+
const rest = args.slice(1);
|
|
3022
|
+
const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
3023
|
+
if (optIdx !== -1) {
|
|
3024
|
+
const next = [...rest];
|
|
3025
|
+
next[optIdx] = { ...rest[optIdx], lookup };
|
|
3026
|
+
return [first, ...next];
|
|
3027
|
+
}
|
|
3028
|
+
const cbIdx = rest.findIndex((a) => typeof a === "function");
|
|
3029
|
+
if (cbIdx === -1) return [first, { lookup }, ...rest];
|
|
3030
|
+
return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
// src/protect/defaults.js
|
|
3034
|
+
var DEFAULT_RESPONSE_RULES = [
|
|
3035
|
+
{
|
|
3036
|
+
id: "resp-private-key",
|
|
3037
|
+
title: "Private key in response body",
|
|
3038
|
+
phase: "response",
|
|
3039
|
+
category: "secret-exposure",
|
|
3040
|
+
action: "redact",
|
|
3041
|
+
prefilter: ["PRIVATE KEY"],
|
|
3042
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
3043
|
+
},
|
|
3044
|
+
{
|
|
3045
|
+
id: "resp-aws-access-key",
|
|
3046
|
+
title: "AWS access key id in response body",
|
|
3047
|
+
phase: "response",
|
|
3048
|
+
category: "secret-exposure",
|
|
3049
|
+
action: "redact",
|
|
3050
|
+
prefilter: ["AKIA", "ASIA"],
|
|
3051
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
3052
|
+
},
|
|
3053
|
+
{
|
|
3054
|
+
id: "resp-gcp-api-key",
|
|
3055
|
+
title: "Google API key in response body",
|
|
3056
|
+
phase: "response",
|
|
3057
|
+
category: "secret-exposure",
|
|
3058
|
+
action: "redact",
|
|
3059
|
+
prefilter: ["AIza"],
|
|
3060
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
3061
|
+
},
|
|
3062
|
+
{
|
|
3063
|
+
id: "resp-vendor-api-key",
|
|
3064
|
+
title: "Vendor API key / token in response body",
|
|
3065
|
+
phase: "response",
|
|
3066
|
+
category: "secret-exposure",
|
|
3067
|
+
action: "redact",
|
|
3068
|
+
// High-signal, prefix-anchored provider tokens that never legitimately appear in a response
|
|
3069
|
+
// body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
|
|
3070
|
+
// Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
|
|
3071
|
+
// (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
|
|
3072
|
+
prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
|
|
3073
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
|
|
3074
|
+
},
|
|
3075
|
+
{
|
|
3076
|
+
id: "resp-supabase-secret-key",
|
|
3077
|
+
title: "Supabase secret key in response body",
|
|
3078
|
+
phase: "response",
|
|
3079
|
+
category: "secret-exposure",
|
|
3080
|
+
action: "redact",
|
|
3081
|
+
// Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
|
|
3082
|
+
// `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
|
|
3083
|
+
// backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
|
|
3084
|
+
// Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
|
|
3085
|
+
// JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
|
|
3086
|
+
//
|
|
3087
|
+
// The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
|
|
3088
|
+
// base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
|
|
3089
|
+
// around it: a variable-length suffix would either run past the key into adjacent syntax or stop
|
|
3090
|
+
// short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
|
|
3091
|
+
// characters, which is not this format.
|
|
3092
|
+
prefilter: ["sb_secret_"],
|
|
3093
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
|
|
3094
|
+
},
|
|
3095
|
+
{
|
|
3096
|
+
id: "resp-supabase-service-role-key",
|
|
3097
|
+
title: "Supabase service_role key in response body",
|
|
3098
|
+
phase: "response",
|
|
3099
|
+
category: "secret-exposure",
|
|
3100
|
+
action: "redact",
|
|
3101
|
+
// Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
|
|
3102
|
+
// entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
|
|
3103
|
+
// by design and a login response carries the user's own access token — so this decides on the
|
|
3104
|
+
// decoded payload rather than on the shape of the token.
|
|
3105
|
+
//
|
|
3106
|
+
// `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
|
|
3107
|
+
// tokens and serve the rest of the response.
|
|
3108
|
+
//
|
|
3109
|
+
// A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
|
|
3110
|
+
// an opt-in, observe-only rule rather than an on-by-default mask.
|
|
3111
|
+
prefilter: ["eyJ"],
|
|
3112
|
+
rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
|
|
3113
|
+
},
|
|
3114
|
+
{
|
|
3115
|
+
id: "resp-db-connection-string",
|
|
3116
|
+
title: "Database connection string with credentials in response body",
|
|
3117
|
+
phase: "response",
|
|
3118
|
+
category: "secret-exposure",
|
|
3119
|
+
action: "redact",
|
|
3120
|
+
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
3121
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
3122
|
+
},
|
|
3123
|
+
{
|
|
3124
|
+
id: "resp-stack-trace",
|
|
3125
|
+
title: "Node stack trace leaking in response body",
|
|
3126
|
+
phase: "response",
|
|
3127
|
+
category: "info-exposure",
|
|
3128
|
+
action: "redact",
|
|
3129
|
+
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
3130
|
+
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
3131
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
3132
|
+
},
|
|
3133
|
+
{
|
|
3134
|
+
id: "resp-sql-error",
|
|
3135
|
+
title: "SQL / ORM error disclosure in response body",
|
|
3136
|
+
phase: "response",
|
|
3137
|
+
category: "info-exposure",
|
|
3138
|
+
action: "redact",
|
|
3139
|
+
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
3140
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
3141
|
+
},
|
|
3142
|
+
{
|
|
3143
|
+
id: "resp-exception-trace",
|
|
3144
|
+
title: "Backend exception / stack trace disclosure in response body",
|
|
3145
|
+
phase: "response",
|
|
3146
|
+
category: "info-exposure",
|
|
3147
|
+
action: "redact",
|
|
3148
|
+
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
3149
|
+
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
3150
|
+
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
3151
|
+
prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
|
|
3152
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }]
|
|
2712
3153
|
}
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
3154
|
+
];
|
|
3155
|
+
var DEFAULT_EGRESS_RULES = [
|
|
3156
|
+
{
|
|
3157
|
+
id: "egress-internal-address",
|
|
3158
|
+
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
3159
|
+
phase: "egress",
|
|
3160
|
+
category: "ssrf",
|
|
3161
|
+
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
2717
3162
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
3163
|
+
];
|
|
3164
|
+
|
|
3165
|
+
// src/protect/block-page.js
|
|
3166
|
+
function escapeHtml(s) {
|
|
3167
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2722
3168
|
}
|
|
2723
|
-
function
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
3169
|
+
function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
3170
|
+
const safeUrl = escapeHtml(url);
|
|
3171
|
+
const safeCode = escapeHtml(code || "patchstack");
|
|
3172
|
+
return `<!DOCTYPE html>
|
|
3173
|
+
<html lang="en">
|
|
3174
|
+
<head>
|
|
3175
|
+
<meta charset="UTF-8">
|
|
3176
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
3177
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3178
|
+
<style type="text/css">
|
|
3179
|
+
.access-denied,.site-url-text{letter-spacing:.01em;color:#fff}body{background:#111216}.main-container{display:flex;justify-content:center;flex-direction:column;align-items:center}.container{width:688px;height:456px;background:#1c1e22;border-radius:16px;margin-top:120px}.content{height:330px;margin:64px}.access-denied{font-family:Verdana;font-style:normal;font-weight:600;font-size:24px;line-height:32px;display:flex;align-items:center}.descp-text,.site-url{display:flex;align-items:center}.site-url{margin-top:32px;width:560px;height:64px;background:#26282c;border-radius:4px}.reason-code-text,.site-url-text{font-family:Verdana;font-style:normal;border:none;background:#26282c}.site-url-text{text-overflow: ellipsis;font-weight:400;font-size:16px;line-height:24px;margin-left:16px;width:85%}.copy-icon{width:19px;height:22px;margin-left:20px}.copy-icon:hover{cursor:pointer}.reason-code-text{font-weight:600;font-size:24px;line-height:32px;letter-spacing:.01em;color:#AFE614;margin-left:16px;width:85%}.descp-text,.return-text{font-size:16px;line-height:24px;letter-spacing:.01em;font-weight:400}.return-text:hover{color: #AFE614}.descp-text,.logoText,.return-text,.secured-text{font-family:Verdana;font-style:normal;color:#fff}.descp{margin-top:32px;width:560px}.bottom-second-containers,.bottom-text-containers{align-items:center;display:flex}.bottom-container{margin-top:40px;display:flex;width:688px}.bottom-second-containers{margin-left:auto}.arrow-vector{margin-left:20px}.return-text{text-align:center;margin-left:12.59px;text-decoration:none}.secured-text{font-weight:400;font-size:11.2px;line-height:16px;display:flex;align-items:center;letter-spacing:.02em;margin-right:8px}.logoText{font-weight:600;font-size:16px;line-height:24px;letter-spacing:.01em;flex:none;order:1;flex-grow:0}.patch-logo{width:120px}.mobile-bottom-container{margin-top:40px;width:372px;display:none}.mobile-bottom-second-containers{margin-top:64px;display:flex;margin-left:16.5px}@media only screen and (max-width:900px){.container{background:#111216;width:370px;height:auto;margin-top:0}.content{margin-left:16.5px}.descp,.site-url{width:340px}.site-url-text{font-weight:400;font-size:14.4px;line-height:20px;letter-spacing:.02em;width:80%}.copy-icon{margin-left:20px}.reason-code-text{font-size:16px;line-height:32px;letter-spacing:.02em;margin-left:16px;width:80%}.descp-text,.return-text{font-size:14.4px;line-height:20px}.descp-text{display:flex;align-items:center;letter-spacing:.02em}.bottom-container{display:none}.mobile-bottom-container{display:block}.return-text{text-align:center;letter-spacing:.01em;color:#fff;margin-left:9.73px}}
|
|
3180
|
+
</style>
|
|
3181
|
+
<title>Access Denied</title>
|
|
3182
|
+
</head>
|
|
3183
|
+
<body>
|
|
3184
|
+
<div class="main-container">
|
|
3185
|
+
<div class="container">
|
|
3186
|
+
<div class="content">
|
|
3187
|
+
<span class="access-denied">Access Denied</span>
|
|
3188
|
+
<div class="site-url">
|
|
3189
|
+
<input id="url" class="site-url-text" type="text"
|
|
3190
|
+
value="${safeUrl}" readonly>
|
|
3191
|
+
<img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='url')">
|
|
3192
|
+
</div>
|
|
3193
|
+
<div class="site-url">
|
|
3194
|
+
<input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
|
|
3195
|
+
<img class="copy-icon" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" alt="Copy Text" onmouseover="hover(this);" onmouseout="unhover(this);" onclick="copyText(id='response')">
|
|
3196
|
+
</div>
|
|
3197
|
+
<div class="descp">
|
|
3198
|
+
<span class="descp-text">This request has been blocked by Patchstack. If this message persists and you are a legitimate user, contact the site administrator with the reference above.</span>
|
|
3199
|
+
</div>
|
|
3200
|
+
</div>
|
|
3201
|
+
</div>
|
|
3202
|
+
<div class="bottom-container">
|
|
3203
|
+
<div class="bottom-text-containers">
|
|
3204
|
+
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
3205
|
+
<a href="/" class="return-text">Return to homepage</a>
|
|
3206
|
+
</div>
|
|
3207
|
+
<div class="bottom-second-containers">
|
|
3208
|
+
<span class="secured-text">This website is secured by</span>
|
|
3209
|
+
<a href="https://patchstack.com" class="logoText">
|
|
3210
|
+
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
3211
|
+
</a>
|
|
3212
|
+
</div>
|
|
3213
|
+
</div>
|
|
3214
|
+
|
|
3215
|
+
<div class="mobile-bottom-container">
|
|
3216
|
+
<div class="mobile-bottom-text-containers">
|
|
3217
|
+
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
3218
|
+
<a href="/" class="return-text">Return to homepage</a>
|
|
3219
|
+
</div>
|
|
3220
|
+
<div class="mobile-bottom-second-containers">
|
|
3221
|
+
<span class="secured-text">This website is secured by</span>
|
|
3222
|
+
<a href="https://patchstack.com" class="logoText">
|
|
3223
|
+
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
3224
|
+
</a>
|
|
3225
|
+
</div>
|
|
3226
|
+
</div>
|
|
3227
|
+
</div>
|
|
3228
|
+
</body>
|
|
3229
|
+
<script>
|
|
3230
|
+
function copyText(id) {
|
|
3231
|
+
var copyText = document.getElementById(id)
|
|
3232
|
+
copyText.select();
|
|
3233
|
+
copyText.setSelectionRange(0, 99999);
|
|
3234
|
+
navigator.clipboard.writeText(copyText.value);
|
|
2728
3235
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
function rulePropertyProblem(rule) {
|
|
2733
|
-
if (!rule || typeof rule !== "object") return null;
|
|
2734
|
-
for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
|
|
2735
|
-
if (rule[property] === void 0 || rule[property] === null) continue;
|
|
2736
|
-
const problem = operandShapeProblem(shape, rule[property]);
|
|
2737
|
-
if (problem) return `"${property}" ${problem}`;
|
|
2738
|
-
}
|
|
2739
|
-
return null;
|
|
2740
|
-
}
|
|
2741
|
-
function actionProblem(action, rule) {
|
|
2742
|
-
if (action === void 0 || action === null) return null;
|
|
2743
|
-
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2744
|
-
const spec = ACTION_PROPERTIES[action];
|
|
2745
|
-
for (const required of spec?.required ?? []) {
|
|
2746
|
-
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2747
|
-
return `action "${action}" needs "${required}"`;
|
|
3236
|
+
|
|
3237
|
+
function hover(element) {
|
|
3238
|
+
element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
|
|
2748
3239
|
}
|
|
2749
|
-
}
|
|
2750
|
-
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2751
|
-
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2752
|
-
const problem = operandShapeProblem(shape, rule[property]);
|
|
2753
|
-
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2754
|
-
}
|
|
2755
|
-
const phase = rule?.phase ?? "request";
|
|
2756
|
-
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2757
|
-
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2758
|
-
}
|
|
2759
|
-
return null;
|
|
2760
|
-
}
|
|
2761
3240
|
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
var PHASES2 = new Set(PHASES);
|
|
2765
|
-
var ACTIONS2 = new Set(ACTIONS);
|
|
2766
|
-
function validateBundle(bundle, opts = {}) {
|
|
2767
|
-
const rejected = [];
|
|
2768
|
-
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2769
|
-
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2770
|
-
const firewall = [];
|
|
2771
|
-
for (const rule of inFirewall) {
|
|
2772
|
-
if (firewall.length >= LIMITS2.maxRules) {
|
|
2773
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2774
|
-
continue;
|
|
3241
|
+
function unhover(element) {
|
|
3242
|
+
element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
|
|
2775
3243
|
}
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3244
|
+
<\/script>
|
|
3245
|
+
</html>`;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
// src/protect/rules/store.js
|
|
3249
|
+
var fsMod;
|
|
3250
|
+
async function loadFs() {
|
|
3251
|
+
if (fsMod !== void 0) return fsMod;
|
|
3252
|
+
try {
|
|
3253
|
+
const [fs, path] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3254
|
+
fsMod = {
|
|
3255
|
+
readFileSync: fs.readFileSync,
|
|
3256
|
+
writeFileSync: fs.writeFileSync,
|
|
3257
|
+
mkdirSync: fs.mkdirSync,
|
|
3258
|
+
join: path.join
|
|
3259
|
+
};
|
|
3260
|
+
} catch {
|
|
3261
|
+
fsMod = null;
|
|
2779
3262
|
}
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
3263
|
+
return fsMod;
|
|
3264
|
+
}
|
|
3265
|
+
function makeStore(options = {}) {
|
|
3266
|
+
let mem = null;
|
|
3267
|
+
const durable = durableTier(options);
|
|
3268
|
+
return {
|
|
3269
|
+
async read() {
|
|
3270
|
+
if (mem) return mem;
|
|
3271
|
+
const env = await durable.read();
|
|
3272
|
+
if (env) mem = env;
|
|
3273
|
+
return env;
|
|
3274
|
+
},
|
|
3275
|
+
async write(env) {
|
|
3276
|
+
if (env) mem = env;
|
|
3277
|
+
await durable.write(env);
|
|
2789
3278
|
}
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
function durableTier(options) {
|
|
3282
|
+
const adapter = options.ruleCache;
|
|
3283
|
+
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
3284
|
+
return {
|
|
3285
|
+
read: async () => {
|
|
3286
|
+
try {
|
|
3287
|
+
return toEnvelope(await adapter.read());
|
|
3288
|
+
} catch {
|
|
3289
|
+
return null;
|
|
3290
|
+
}
|
|
3291
|
+
},
|
|
3292
|
+
write: async (env) => {
|
|
3293
|
+
try {
|
|
3294
|
+
await adapter.write(env);
|
|
3295
|
+
} catch {
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
};
|
|
2793
3299
|
}
|
|
3300
|
+
const dir = options.cacheDir;
|
|
2794
3301
|
return {
|
|
2795
|
-
|
|
2796
|
-
|
|
3302
|
+
read: async () => cacheRead(dir),
|
|
3303
|
+
write: async (env) => cacheWrite(dir, env)
|
|
2797
3304
|
};
|
|
2798
3305
|
}
|
|
2799
|
-
function
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
const actionReason = actionProblem(rule.action, rule);
|
|
2809
|
-
if (actionReason) return actionReason;
|
|
2810
|
-
const scopeReason = whenProblem(rule.when);
|
|
2811
|
-
if (scopeReason) return scopeReason;
|
|
2812
|
-
const propertyReason = rulePropertyProblem(rule);
|
|
2813
|
-
if (propertyReason) return propertyReason;
|
|
2814
|
-
return conditionsProblem(rule.rule_v2);
|
|
3306
|
+
async function cacheWrite(dir, env) {
|
|
3307
|
+
if (!dir) return;
|
|
3308
|
+
const fs = await loadFs();
|
|
3309
|
+
if (!fs) return;
|
|
3310
|
+
try {
|
|
3311
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3312
|
+
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
3313
|
+
} catch {
|
|
3314
|
+
}
|
|
2815
3315
|
}
|
|
2816
|
-
function
|
|
2817
|
-
if (!
|
|
2818
|
-
|
|
2819
|
-
if (
|
|
2820
|
-
|
|
2821
|
-
return
|
|
3316
|
+
async function cacheRead(dir) {
|
|
3317
|
+
if (!dir) return null;
|
|
3318
|
+
const fs = await loadFs();
|
|
3319
|
+
if (!fs) return null;
|
|
3320
|
+
try {
|
|
3321
|
+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
3322
|
+
} catch {
|
|
3323
|
+
return null;
|
|
2822
3324
|
}
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
}
|
|
2831
|
-
const parameterReason = parameterProblem(c.parameter);
|
|
2832
|
-
if (parameterReason) return parameterReason;
|
|
2833
|
-
const mutationReason = mutationsProblem(c.mutations);
|
|
2834
|
-
if (mutationReason) return mutationReason;
|
|
2835
|
-
const matchReason = matchProblem(c.match, c.parameter);
|
|
2836
|
-
if (matchReason) return matchReason;
|
|
2837
|
-
const m = c.match;
|
|
2838
|
-
if (m.type === "regex") {
|
|
2839
|
-
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2840
|
-
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2841
|
-
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2842
|
-
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2843
|
-
}
|
|
2844
|
-
if (m.match) {
|
|
2845
|
-
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2846
|
-
if (nested) return nested;
|
|
2847
|
-
}
|
|
3325
|
+
}
|
|
3326
|
+
function toEnvelope(value) {
|
|
3327
|
+
if (!value || typeof value !== "object") return null;
|
|
3328
|
+
if (value.bundle && typeof value.bundle === "object") {
|
|
3329
|
+
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
3330
|
+
}
|
|
3331
|
+
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
3332
|
+
return { bundle: value, etag: null };
|
|
2848
3333
|
}
|
|
2849
3334
|
return null;
|
|
2850
3335
|
}
|
|
@@ -2870,67 +3355,70 @@ function reportRejections(rejected, options, label) {
|
|
|
2870
3355
|
`${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
|
|
2871
3356
|
), "onError");
|
|
2872
3357
|
}
|
|
2873
|
-
function fromSource(bundle, reason) {
|
|
2874
|
-
return
|
|
3358
|
+
function fromSource(bundle, origin, reason) {
|
|
3359
|
+
return {
|
|
3360
|
+
...bundle,
|
|
3361
|
+
source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
|
|
3362
|
+
};
|
|
2875
3363
|
}
|
|
2876
3364
|
async function resolveRules(options, store, ctx = {}) {
|
|
2877
3365
|
const timeoutMs = ctx.timeoutMs;
|
|
2878
3366
|
if (options.siteUuid) {
|
|
2879
3367
|
const prior = await store.read();
|
|
2880
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth,
|
|
3368
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
|
|
2881
3369
|
const res = await client.getRules();
|
|
2882
|
-
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3370
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
|
|
2883
3371
|
if (res.success && !res.notModified) {
|
|
2884
3372
|
const rejected = liveUpdateRejections(res, options);
|
|
2885
3373
|
if (rejected.length > 0) {
|
|
2886
3374
|
reportRejections(rejected, options, "rule update rejected");
|
|
2887
|
-
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2888
|
-
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2889
|
-
return fromSource(emptyBundle(), "update rejected");
|
|
3375
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
|
|
3376
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
|
|
3377
|
+
return fromSource(emptyBundle(), "empty", "update rejected");
|
|
2890
3378
|
}
|
|
2891
3379
|
const bundle = normalizeBundle(res, options);
|
|
2892
3380
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2893
|
-
return fromSource(bundle);
|
|
3381
|
+
return fromSource(bundle, "api");
|
|
2894
3382
|
}
|
|
2895
3383
|
if (prior?.bundle) {
|
|
2896
3384
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2897
|
-
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3385
|
+
return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
|
|
2898
3386
|
}
|
|
2899
3387
|
if (options.rules) {
|
|
2900
3388
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2901
|
-
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
3389
|
+
return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
|
|
2902
3390
|
}
|
|
2903
3391
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2904
|
-
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3392
|
+
return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
|
|
2905
3393
|
}
|
|
2906
3394
|
if (options.token) {
|
|
2907
3395
|
const prior = await store.read();
|
|
2908
3396
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2909
3397
|
const res = await client.getRules();
|
|
2910
|
-
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3398
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
|
|
2911
3399
|
if (res.success && !res.notModified) {
|
|
2912
3400
|
const rejected = liveUpdateRejections(res, options);
|
|
2913
3401
|
if (rejected.length > 0) {
|
|
2914
3402
|
reportRejections(rejected, options, "rule update rejected");
|
|
2915
|
-
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2916
|
-
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2917
|
-
return fromSource(emptyBundle(), "update rejected");
|
|
3403
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
|
|
3404
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
|
|
3405
|
+
return fromSource(emptyBundle(), "empty", "update rejected");
|
|
2918
3406
|
}
|
|
2919
3407
|
const bundle = normalizeBundle(res, options);
|
|
2920
3408
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2921
|
-
return fromSource(bundle);
|
|
3409
|
+
return fromSource(bundle, "api");
|
|
2922
3410
|
}
|
|
2923
3411
|
if (prior?.bundle) {
|
|
2924
3412
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2925
|
-
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3413
|
+
return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
|
|
2926
3414
|
}
|
|
2927
3415
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2928
|
-
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3416
|
+
return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
|
|
2929
3417
|
}
|
|
2930
3418
|
if (options.rules) {
|
|
2931
|
-
return fromSource(normalizeBundle(options.rules, options));
|
|
3419
|
+
return fromSource(normalizeBundle(options.rules, options), "bundled");
|
|
2932
3420
|
}
|
|
2933
|
-
return fromSource(emptyBundle());
|
|
3421
|
+
return fromSource(emptyBundle(), "empty");
|
|
2934
3422
|
}
|
|
2935
3423
|
function normalizeBundle(b, options = {}) {
|
|
2936
3424
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -3026,6 +3514,164 @@ var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
|
3026
3514
|
var DEFAULT_FLUSH_MS = 5e3;
|
|
3027
3515
|
var MAX_BATCH = 50;
|
|
3028
3516
|
var MAX_QUEUE = 500;
|
|
3517
|
+
var MAX_ATTEMPTS = 4;
|
|
3518
|
+
var RETRY_BASE_MS = 1e3;
|
|
3519
|
+
var RETRY_CAP_MS = 3e4;
|
|
3520
|
+
var RETRYABLE_EXPLICIT = /* @__PURE__ */ new Set([408, 425, 429]);
|
|
3521
|
+
function worthRetrying(status) {
|
|
3522
|
+
if (status === null) return true;
|
|
3523
|
+
return RETRYABLE_EXPLICIT.has(status) || status >= 500 && status <= 599;
|
|
3524
|
+
}
|
|
3525
|
+
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
3526
|
+
var STOP_BUDGET_MS = 5e3;
|
|
3527
|
+
var MAX_ROUTE_CHARS = 256;
|
|
3528
|
+
var MAX_PARAMETERS = 25;
|
|
3529
|
+
var MAX_PARAMETER_CHARS = 64;
|
|
3530
|
+
var MAX_IDENTIFIER_CHARS = 256;
|
|
3531
|
+
var MAX_CAPTURED_VALUES = 10;
|
|
3532
|
+
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
3533
|
+
var MAX_QUERY_KEYS = 10;
|
|
3534
|
+
var MAX_METHOD_CHARS = 16;
|
|
3535
|
+
var MAX_BODY_BYTES2 = 64 * 1024;
|
|
3536
|
+
function queryKeysOf(path) {
|
|
3537
|
+
if (typeof path !== "string") return { keys: [], total: 0 };
|
|
3538
|
+
const start = path.indexOf("?");
|
|
3539
|
+
if (start === -1) return { keys: [], total: 0 };
|
|
3540
|
+
let names;
|
|
3541
|
+
try {
|
|
3542
|
+
names = [...new URLSearchParams(path.slice(start + 1)).keys()];
|
|
3543
|
+
} catch {
|
|
3544
|
+
return { keys: [], total: 0 };
|
|
3545
|
+
}
|
|
3546
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3547
|
+
const keys = [];
|
|
3548
|
+
for (const name of names) {
|
|
3549
|
+
if (name === "") continue;
|
|
3550
|
+
if (seen.has(name)) continue;
|
|
3551
|
+
seen.add(name);
|
|
3552
|
+
if (keys.length < MAX_QUERY_KEYS) keys.push(name);
|
|
3553
|
+
}
|
|
3554
|
+
return { keys, total: seen.size };
|
|
3555
|
+
}
|
|
3556
|
+
function boundCapture(capture) {
|
|
3557
|
+
if (!capture || typeof capture !== "object" || Array.isArray(capture)) return null;
|
|
3558
|
+
const truncated = [];
|
|
3559
|
+
const own = (object, key) => Object.hasOwn(object, key) ? object[key] : void 0;
|
|
3560
|
+
const declaredPlan = own(capture, "plan");
|
|
3561
|
+
const plan = typeof declaredPlan === "string" ? capText(declaredPlan, MAX_IDENTIFIER_CHARS) : null;
|
|
3562
|
+
if (plan === null) return null;
|
|
3563
|
+
if (plan.truncated) truncated.push("plan");
|
|
3564
|
+
const out = { plan: plan.value };
|
|
3565
|
+
let overCap = 0;
|
|
3566
|
+
let rejected = 0;
|
|
3567
|
+
const declaredValues = own(capture, "values");
|
|
3568
|
+
if (Array.isArray(declaredValues) && declaredValues.length > 0) {
|
|
3569
|
+
const values = [];
|
|
3570
|
+
for (const entry of declaredValues) {
|
|
3571
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3572
|
+
rejected += 1;
|
|
3573
|
+
continue;
|
|
3574
|
+
}
|
|
3575
|
+
const declaredParameter = own(entry, "parameter");
|
|
3576
|
+
const text = scalarText(own(entry, "value"));
|
|
3577
|
+
if (typeof declaredParameter !== "string" || text === null) {
|
|
3578
|
+
rejected += 1;
|
|
3579
|
+
continue;
|
|
3580
|
+
}
|
|
3581
|
+
if (values.length >= MAX_CAPTURED_VALUES) {
|
|
3582
|
+
overCap += 1;
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
const label = capText(declaredParameter, MAX_PARAMETER_CHARS);
|
|
3586
|
+
const value = capText(text, MAX_CAPTURED_VALUE_CHARS);
|
|
3587
|
+
if (label.truncated && !truncated.includes("parameter")) truncated.push("parameter");
|
|
3588
|
+
if (value.truncated && !truncated.includes("value")) truncated.push("value");
|
|
3589
|
+
values.push({
|
|
3590
|
+
parameter: label.value,
|
|
3591
|
+
value: value.value,
|
|
3592
|
+
...own(entry, "truncated") === true || value.truncated ? { truncated: true } : {}
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
if (overCap > 0) truncated.push("values");
|
|
3596
|
+
if (values.length > 0) out.values = values;
|
|
3597
|
+
}
|
|
3598
|
+
const declaredRaw = own(capture, "raw");
|
|
3599
|
+
if (declaredRaw !== void 0 && declaredRaw !== null) {
|
|
3600
|
+
const rawValue = declaredRaw && typeof declaredRaw === "object" ? own(declaredRaw, "value") : void 0;
|
|
3601
|
+
if (typeof rawValue === "string") {
|
|
3602
|
+
const raw = capText(rawValue, MAX_CAPTURED_VALUE_CHARS);
|
|
3603
|
+
out.raw = {
|
|
3604
|
+
value: raw.value,
|
|
3605
|
+
...own(declaredRaw, "truncated") === true || raw.truncated ? { truncated: true } : {}
|
|
3606
|
+
};
|
|
3607
|
+
} else {
|
|
3608
|
+
rejected += 1;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
const declaredCount = (key) => {
|
|
3612
|
+
const value = own(capture, key);
|
|
3613
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
3614
|
+
};
|
|
3615
|
+
const totals = {
|
|
3616
|
+
omitted: declaredCount("omitted") + overCap,
|
|
3617
|
+
unsupported: declaredCount("unsupported") + rejected,
|
|
3618
|
+
failed: declaredCount("failed")
|
|
3619
|
+
};
|
|
3620
|
+
for (const [key, total] of Object.entries(totals)) {
|
|
3621
|
+
if (total > 0) out[key] = total;
|
|
3622
|
+
}
|
|
3623
|
+
if (own(capture, "unavailable") === true) out.unavailable = true;
|
|
3624
|
+
if (truncated.length > 0) out.truncated = truncated;
|
|
3625
|
+
return out;
|
|
3626
|
+
}
|
|
3627
|
+
function scalarText(value) {
|
|
3628
|
+
if (typeof value === "string") return value;
|
|
3629
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
3630
|
+
if (typeof value === "boolean") return String(value);
|
|
3631
|
+
return null;
|
|
3632
|
+
}
|
|
3633
|
+
function makeInstanceId() {
|
|
3634
|
+
try {
|
|
3635
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
3636
|
+
if (typeof uuid === "string" && uuid !== "") return uuid;
|
|
3637
|
+
} catch {
|
|
3638
|
+
}
|
|
3639
|
+
return `${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
|
|
3640
|
+
}
|
|
3641
|
+
function capText(value, limit) {
|
|
3642
|
+
const text = typeof value === "string" ? value : "";
|
|
3643
|
+
return text.length > limit ? { value: text.slice(0, limit), truncated: true } : { value: text, truncated: false };
|
|
3644
|
+
}
|
|
3645
|
+
function retryDelayMs(attempts, retryAfter, random = Math.random) {
|
|
3646
|
+
const advertised = parseRetryAfter(retryAfter);
|
|
3647
|
+
if (advertised !== null) return Math.min(advertised, RETRY_CAP_MS);
|
|
3648
|
+
const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_CAP_MS);
|
|
3649
|
+
return Math.min(RETRY_CAP_MS, Math.round(backoff * (0.75 + random() * 0.5)));
|
|
3650
|
+
}
|
|
3651
|
+
function parseRetryAfter(value) {
|
|
3652
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
3653
|
+
const seconds = Number(value.trim());
|
|
3654
|
+
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1e3 : null;
|
|
3655
|
+
const at = Date.parse(value);
|
|
3656
|
+
if (Number.isNaN(at)) return null;
|
|
3657
|
+
return Math.max(0, at - Date.now());
|
|
3658
|
+
}
|
|
3659
|
+
var encoder = new TextEncoder();
|
|
3660
|
+
function byteLength(text) {
|
|
3661
|
+
return encoder.encode(text).length;
|
|
3662
|
+
}
|
|
3663
|
+
function splitToFit(events, maxBytes, wrap = (batch) => ({ detections: batch })) {
|
|
3664
|
+
const batch = events.slice();
|
|
3665
|
+
const rest = [];
|
|
3666
|
+
while (batch.length > 1 && byteLength(JSON.stringify(wrap(batch))) > maxBytes) {
|
|
3667
|
+
rest.unshift(batch.pop());
|
|
3668
|
+
}
|
|
3669
|
+
return [batch, rest];
|
|
3670
|
+
}
|
|
3671
|
+
function unattended(timer) {
|
|
3672
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
3673
|
+
return timer;
|
|
3674
|
+
}
|
|
3029
3675
|
function ruleParameters(rule) {
|
|
3030
3676
|
const out = /* @__PURE__ */ new Set();
|
|
3031
3677
|
const walk = (conditions) => {
|
|
@@ -3060,12 +3706,22 @@ function createDetectionReporter(opts) {
|
|
|
3060
3706
|
},
|
|
3061
3707
|
flush() {
|
|
3062
3708
|
},
|
|
3063
|
-
stop()
|
|
3064
|
-
},
|
|
3709
|
+
stop: () => Promise.resolve(),
|
|
3065
3710
|
setRulesEtag() {
|
|
3066
3711
|
},
|
|
3712
|
+
announce() {
|
|
3713
|
+
},
|
|
3067
3714
|
dropped: () => 0,
|
|
3068
|
-
health: () => ({
|
|
3715
|
+
health: () => ({
|
|
3716
|
+
sent: 0,
|
|
3717
|
+
delivered: 0,
|
|
3718
|
+
failed: 0,
|
|
3719
|
+
dropped: 0,
|
|
3720
|
+
retried: 0,
|
|
3721
|
+
reauthorized: 0,
|
|
3722
|
+
lastDeliveredAt: null,
|
|
3723
|
+
capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }
|
|
3724
|
+
})
|
|
3069
3725
|
};
|
|
3070
3726
|
}
|
|
3071
3727
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
@@ -3079,47 +3735,233 @@ function createDetectionReporter(opts) {
|
|
|
3079
3735
|
let failed = 0;
|
|
3080
3736
|
let droppedTotal = 0;
|
|
3081
3737
|
let lastDeliveredAt = null;
|
|
3738
|
+
let capabilityAnnounced = 0;
|
|
3739
|
+
let capabilityAcknowledged = 0;
|
|
3740
|
+
let capabilityFailed = 0;
|
|
3741
|
+
let lastCapabilityAckAt = null;
|
|
3082
3742
|
let queue = [];
|
|
3083
3743
|
let timer = null;
|
|
3744
|
+
let retryTimer = null;
|
|
3084
3745
|
let stopped = false;
|
|
3085
3746
|
let dropped = 0;
|
|
3747
|
+
let retried = 0;
|
|
3748
|
+
let capabilityRetried = 0;
|
|
3749
|
+
let flushRequested = false;
|
|
3750
|
+
let draining = false;
|
|
3751
|
+
let drainResolve = null;
|
|
3752
|
+
let drainPromise = null;
|
|
3753
|
+
let budgetTimer = null;
|
|
3754
|
+
let epoch = 0;
|
|
3755
|
+
let reauthorized = 0;
|
|
3756
|
+
let sending = false;
|
|
3757
|
+
let inFlight = null;
|
|
3758
|
+
let pendingState = null;
|
|
3759
|
+
const instanceId = makeInstanceId();
|
|
3760
|
+
let sequence = 0;
|
|
3761
|
+
const bodyFor = (events, droppedWith, state) => ({
|
|
3762
|
+
detections: events,
|
|
3763
|
+
// The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer
|
|
3764
|
+
// computing a false-positive rate needs to know its denominator is short, and silence about that
|
|
3765
|
+
// would make a truncated sample look like a complete one.
|
|
3766
|
+
dropped: droppedWith,
|
|
3767
|
+
...state !== null ? { reporting_state: state } : {}
|
|
3768
|
+
});
|
|
3769
|
+
const takeBatch = (droppedWith, state) => {
|
|
3770
|
+
const [batch, rest] = splitToFit(
|
|
3771
|
+
queue.splice(0, MAX_BATCH),
|
|
3772
|
+
MAX_BODY_BYTES2,
|
|
3773
|
+
(events) => bodyFor(events, droppedWith, state)
|
|
3774
|
+
);
|
|
3775
|
+
if (rest.length > 0) queue.unshift(...rest);
|
|
3776
|
+
return batch;
|
|
3777
|
+
};
|
|
3778
|
+
const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: ATTEMPT_TIMEOUT_MS };
|
|
3779
|
+
const detectionsUrl = `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`;
|
|
3780
|
+
const post = async (body, key, signal, transport) => (
|
|
3781
|
+
// Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries
|
|
3782
|
+
// once with a fresh one. A cached token can stop being valid before it expires, and the server's
|
|
3783
|
+
// refusal is authoritative over our own clock; the batch's own headers, this key included, are
|
|
3784
|
+
// carried through both sends, so the redelivery is still recognisable as the same batch.
|
|
3785
|
+
pulseFetch(
|
|
3786
|
+
authConfig,
|
|
3787
|
+
detectionsUrl,
|
|
3788
|
+
{
|
|
3789
|
+
method: "POST",
|
|
3790
|
+
...signal ? { signal } : {},
|
|
3791
|
+
headers: {
|
|
3792
|
+
"Content-Type": "application/json",
|
|
3793
|
+
Accept: "application/json",
|
|
3794
|
+
"User-Agent": "@patchstack/connect",
|
|
3795
|
+
// The same key on every attempt of one batch: an acknowledgement can be lost after the server
|
|
3796
|
+
// has already taken the batch, and a redelivery has to be identifiable as the same one.
|
|
3797
|
+
"Idempotency-Key": key
|
|
3798
|
+
},
|
|
3799
|
+
body: JSON.stringify(body)
|
|
3800
|
+
},
|
|
3801
|
+
transport
|
|
3802
|
+
)
|
|
3803
|
+
);
|
|
3804
|
+
const abandon = () => {
|
|
3805
|
+
if (!inFlight) return;
|
|
3806
|
+
failed += inFlight.events.length;
|
|
3807
|
+
if (inFlight.state !== null) capabilityFailed += 1;
|
|
3808
|
+
inFlight = null;
|
|
3809
|
+
};
|
|
3810
|
+
const settle = () => {
|
|
3811
|
+
if (!inFlight) return;
|
|
3812
|
+
if (inFlight.events.length > 0) {
|
|
3813
|
+
delivered += inFlight.events.length;
|
|
3814
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3815
|
+
}
|
|
3816
|
+
if (inFlight.state !== null) {
|
|
3817
|
+
capabilityAcknowledged += 1;
|
|
3818
|
+
lastCapabilityAckAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3819
|
+
}
|
|
3820
|
+
inFlight = null;
|
|
3821
|
+
};
|
|
3822
|
+
const attempt = async () => {
|
|
3823
|
+
if (!inFlight) return;
|
|
3824
|
+
const mine = epoch;
|
|
3825
|
+
sending = true;
|
|
3826
|
+
inFlight.attempts += 1;
|
|
3827
|
+
if (inFlight.attempts > 1) {
|
|
3828
|
+
if (inFlight.events.length > 0) retried += 1;
|
|
3829
|
+
if (inFlight.state !== null) capabilityRetried += 1;
|
|
3830
|
+
}
|
|
3831
|
+
const body = bodyFor(inFlight.events, inFlight.dropped, inFlight.state);
|
|
3832
|
+
let status = null;
|
|
3833
|
+
let retryAfter = null;
|
|
3834
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
3835
|
+
inFlight.controller = controller;
|
|
3836
|
+
const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null;
|
|
3837
|
+
inFlight.timeout = timeout;
|
|
3838
|
+
let sends = 0;
|
|
3839
|
+
const counted = (url, init) => {
|
|
3840
|
+
if (String(url) === detectionsUrl) {
|
|
3841
|
+
sends += 1;
|
|
3842
|
+
if (sends > 1 && mine === epoch) reauthorized += 1;
|
|
3843
|
+
}
|
|
3844
|
+
return fetchImpl(url, init);
|
|
3845
|
+
};
|
|
3846
|
+
try {
|
|
3847
|
+
const res = await post(body, inFlight.key, controller?.signal, counted);
|
|
3848
|
+
if (mine !== epoch) return;
|
|
3849
|
+
if (res && res.ok) {
|
|
3850
|
+
settle();
|
|
3851
|
+
if (timeout) clearTimeout(timeout);
|
|
3852
|
+
finish();
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
status = typeof res?.status === "number" ? res.status : 0;
|
|
3856
|
+
retryAfter = res?.headers?.get?.("retry-after") ?? null;
|
|
3857
|
+
} catch {
|
|
3858
|
+
status = null;
|
|
3859
|
+
} finally {
|
|
3860
|
+
if (timeout) clearTimeout(timeout);
|
|
3861
|
+
if (inFlight && inFlight.controller === controller) {
|
|
3862
|
+
inFlight.controller = null;
|
|
3863
|
+
inFlight.timeout = null;
|
|
3864
|
+
}
|
|
3865
|
+
}
|
|
3866
|
+
if (mine !== epoch) return;
|
|
3867
|
+
const retryable = worthRetrying(status);
|
|
3868
|
+
if (retryable && inFlight.attempts < MAX_ATTEMPTS && !stopped) {
|
|
3869
|
+
const delay = retryDelayMs(inFlight.attempts, retryAfter);
|
|
3870
|
+
sending = false;
|
|
3871
|
+
retryTimer = unattended(
|
|
3872
|
+
setTimeout(() => {
|
|
3873
|
+
retryTimer = null;
|
|
3874
|
+
void attempt();
|
|
3875
|
+
}, delay)
|
|
3876
|
+
);
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
abandon();
|
|
3880
|
+
finish();
|
|
3881
|
+
};
|
|
3882
|
+
const finish = () => {
|
|
3883
|
+
sending = false;
|
|
3884
|
+
kick();
|
|
3885
|
+
};
|
|
3886
|
+
const arm = () => {
|
|
3887
|
+
if (!timer) {
|
|
3888
|
+
timer = unattended(
|
|
3889
|
+
setTimeout(() => {
|
|
3890
|
+
timer = null;
|
|
3891
|
+
flushRequested = true;
|
|
3892
|
+
kick();
|
|
3893
|
+
}, flushMs)
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
};
|
|
3897
|
+
const due = () => draining || pendingState !== null || queue.length >= MAX_BATCH || flushRequested;
|
|
3898
|
+
const kick = () => {
|
|
3899
|
+
if (sending || inFlight) return;
|
|
3900
|
+
if (typeof fetchImpl !== "function") {
|
|
3901
|
+
if (draining) drained();
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (stopped && !draining) return;
|
|
3905
|
+
if (queue.length === 0 && pendingState === null) {
|
|
3906
|
+
flushRequested = false;
|
|
3907
|
+
if (draining) drained();
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
if (!due()) {
|
|
3911
|
+
arm();
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
flushRequested = false;
|
|
3915
|
+
const droppedWith = dropped;
|
|
3916
|
+
dropped = 0;
|
|
3917
|
+
droppedTotal += droppedWith;
|
|
3918
|
+
const state = pendingState;
|
|
3919
|
+
pendingState = null;
|
|
3920
|
+
const events = takeBatch(droppedWith, state);
|
|
3921
|
+
sent += events.length;
|
|
3922
|
+
if (state !== null) capabilityAnnounced += 1;
|
|
3923
|
+
sequence += 1;
|
|
3924
|
+
inFlight = { key: `${instanceId}-${sequence}`, events, dropped: droppedWith, state, attempts: 0 };
|
|
3925
|
+
void attempt();
|
|
3926
|
+
};
|
|
3927
|
+
const terminate = () => {
|
|
3928
|
+
epoch += 1;
|
|
3929
|
+
if (inFlight) {
|
|
3930
|
+
inFlight.controller?.abort();
|
|
3931
|
+
if (inFlight.timeout) clearTimeout(inFlight.timeout);
|
|
3932
|
+
failed += inFlight.events.length;
|
|
3933
|
+
if (inFlight.state !== null) capabilityFailed += 1;
|
|
3934
|
+
inFlight = null;
|
|
3935
|
+
}
|
|
3936
|
+
if (pendingState !== null) {
|
|
3937
|
+
pendingState = null;
|
|
3938
|
+
capabilityAnnounced += 1;
|
|
3939
|
+
capabilityFailed += 1;
|
|
3940
|
+
}
|
|
3941
|
+
sending = false;
|
|
3942
|
+
drained();
|
|
3943
|
+
};
|
|
3944
|
+
const drained = () => {
|
|
3945
|
+
draining = false;
|
|
3946
|
+
if (queue.length > 0) {
|
|
3947
|
+
droppedTotal += queue.length;
|
|
3948
|
+
queue = [];
|
|
3949
|
+
}
|
|
3950
|
+
if (budgetTimer) {
|
|
3951
|
+
clearTimeout(budgetTimer);
|
|
3952
|
+
budgetTimer = null;
|
|
3953
|
+
}
|
|
3954
|
+
const resolve = drainResolve;
|
|
3955
|
+
drainResolve = null;
|
|
3956
|
+
if (resolve) resolve();
|
|
3957
|
+
};
|
|
3086
3958
|
const flush = () => {
|
|
3087
3959
|
if (timer) {
|
|
3088
3960
|
clearTimeout(timer);
|
|
3089
3961
|
timer = null;
|
|
3090
3962
|
}
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
const droppedWith = dropped;
|
|
3094
|
-
dropped = 0;
|
|
3095
|
-
droppedTotal += droppedWith;
|
|
3096
|
-
sent += batch.length;
|
|
3097
|
-
void (async () => {
|
|
3098
|
-
try {
|
|
3099
|
-
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
3100
|
-
method: "POST",
|
|
3101
|
-
headers: {
|
|
3102
|
-
"Content-Type": "application/json",
|
|
3103
|
-
Accept: "application/json",
|
|
3104
|
-
"User-Agent": "@patchstack/connect",
|
|
3105
|
-
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
3106
|
-
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
3107
|
-
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
3108
|
-
// posting into a 401.
|
|
3109
|
-
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
3110
|
-
},
|
|
3111
|
-
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
3112
|
-
});
|
|
3113
|
-
if (res && res.ok) {
|
|
3114
|
-
delivered += batch.length;
|
|
3115
|
-
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3116
|
-
} else {
|
|
3117
|
-
failed += batch.length;
|
|
3118
|
-
}
|
|
3119
|
-
} catch {
|
|
3120
|
-
failed += batch.length;
|
|
3121
|
-
}
|
|
3122
|
-
})();
|
|
3963
|
+
flushRequested = true;
|
|
3964
|
+
kick();
|
|
3123
3965
|
};
|
|
3124
3966
|
return {
|
|
3125
3967
|
/**
|
|
@@ -3138,32 +3980,144 @@ function createDetectionReporter(opts) {
|
|
|
3138
3980
|
queue.shift();
|
|
3139
3981
|
dropped++;
|
|
3140
3982
|
}
|
|
3983
|
+
const query = queryKeysOf(detection.path);
|
|
3984
|
+
const method = typeof detection.method === "string" ? capText(detection.method, MAX_METHOD_CHARS) : null;
|
|
3985
|
+
const userAgent = typeof detection.userAgent === "string" && detection.userAgent !== "" ? capText(detection.userAgent, MAX_IDENTIFIER_CHARS) : null;
|
|
3986
|
+
const queryKeys = query.keys.map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
3987
|
+
const capture = boundCapture(detection.capture);
|
|
3988
|
+
const rawRoute = routeOf(detection.path);
|
|
3989
|
+
const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
|
|
3990
|
+
const allParameters = ruleParameters(detection.rule);
|
|
3991
|
+
const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
3992
|
+
const truncated = [];
|
|
3993
|
+
if (route.truncated) truncated.push("route");
|
|
3994
|
+
if (method?.truncated) truncated.push("method");
|
|
3995
|
+
if (userAgent?.truncated) truncated.push("user_agent");
|
|
3996
|
+
if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
|
|
3997
|
+
truncated.push("query_keys");
|
|
3998
|
+
}
|
|
3999
|
+
if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
4000
|
+
truncated.push("parameters");
|
|
4001
|
+
}
|
|
4002
|
+
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
4003
|
+
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
4004
|
+
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
4005
|
+
for (const [name, field] of [
|
|
4006
|
+
["rule_id", id],
|
|
4007
|
+
["rule_revision", revision],
|
|
4008
|
+
["rules_etag", etag]
|
|
4009
|
+
]) {
|
|
4010
|
+
if (field.truncated) truncated.push(name);
|
|
4011
|
+
}
|
|
3141
4012
|
queue.push({
|
|
3142
|
-
rule_id:
|
|
3143
|
-
route:
|
|
3144
|
-
parameters:
|
|
4013
|
+
rule_id: id.value,
|
|
4014
|
+
route: route.value,
|
|
4015
|
+
parameters: parameters.map((entry) => entry.value),
|
|
4016
|
+
// Present only when something was shortened, so its absence is not a claim of its own.
|
|
4017
|
+
...truncated.length > 0 ? { truncated } : {},
|
|
4018
|
+
// Only when parameters were actually left out. Reporting a total because some OTHER field was
|
|
4019
|
+
// shortened states that parameters were omitted when none were.
|
|
4020
|
+
...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
|
|
4021
|
+
method: method === null ? null : method.value,
|
|
4022
|
+
// The rest of the URL, as names only. `route` is the path; together they say what was requested
|
|
4023
|
+
// without saying what was in it.
|
|
4024
|
+
query_keys: queryKeys.map((entry) => entry.value),
|
|
4025
|
+
// Present only when the list is short, so its absence is not a claim of its own.
|
|
4026
|
+
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
4027
|
+
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
4028
|
+
user_agent: userAgent === null ? null : userAgent.value,
|
|
3145
4029
|
phase: detection.phase ?? null,
|
|
3146
4030
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3147
4031
|
// saw traffic it would have stopped.
|
|
3148
4032
|
enforced: detection.mode === "block",
|
|
3149
|
-
rules_etag: rulesEtag,
|
|
4033
|
+
rules_etag: rulesEtag === null ? null : etag.value,
|
|
3150
4034
|
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
3151
4035
|
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
3152
4036
|
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
3153
4037
|
// bundle carried none.
|
|
3154
|
-
rule_revision: revisionOf(detection.rule),
|
|
4038
|
+
rule_revision: revisionOf(detection.rule) === null ? null : revision.value,
|
|
4039
|
+
// What the rule was permitted to show, and what it showed. Present only when a plan was derived,
|
|
4040
|
+
// which is to say only for a phase that has a reading to derive from.
|
|
4041
|
+
...capture ? { capture } : {},
|
|
4042
|
+
// The client address and where it came from. `client_ip` is omitted entirely when there is none,
|
|
4043
|
+
// so a present-but-empty field cannot read as a failed lookup of a real address; the provenance is
|
|
4044
|
+
// always present, because "this could not be established" is the part a reader needs.
|
|
4045
|
+
...clientIpFields({ ip: detection.ip ?? null, source: detection.clientIpSource ?? "unavailable" }),
|
|
3155
4046
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3156
4047
|
});
|
|
3157
|
-
|
|
3158
|
-
flush();
|
|
3159
|
-
return;
|
|
3160
|
-
}
|
|
3161
|
-
if (!timer) timer = setTimeout(flush, flushMs);
|
|
4048
|
+
kick();
|
|
3162
4049
|
},
|
|
3163
4050
|
flush,
|
|
4051
|
+
/**
|
|
4052
|
+
* Tell the platform which reporting state this guard settled on.
|
|
4053
|
+
*
|
|
4054
|
+
* The state also travels on the rules fetch, but that request is made BEFORE the fetch decides
|
|
4055
|
+
* whether the rules are the platform's — so a site booting with an empty cache declares that it holds
|
|
4056
|
+
* no managed rules, then receives them. Without this, the corrected state would wait for the next
|
|
4057
|
+
* refresh, and a guard with refreshing switched off has none.
|
|
4058
|
+
*
|
|
4059
|
+
* Goes through the same transport as events, so it inherits the retry, the idempotency key and the
|
|
4060
|
+
* single-flight bound. Only the newest state is kept: calling this twice before a send declares the
|
|
4061
|
+
* second, because what the platform needs is the state now, not how it got there. A declaration that
|
|
4062
|
+
* exhausts its retries is dropped rather than held — the state travels on every rules fetch too, so
|
|
4063
|
+
* the next one corrects it.
|
|
4064
|
+
*
|
|
4065
|
+
* @param {string} state
|
|
4066
|
+
*/
|
|
4067
|
+
announce(state) {
|
|
4068
|
+
if (stopped || typeof fetchImpl !== "function" || typeof state !== "string") return;
|
|
4069
|
+
pendingState = state;
|
|
4070
|
+
queueMicrotask(() => {
|
|
4071
|
+
if (!stopped) kick();
|
|
4072
|
+
});
|
|
4073
|
+
},
|
|
4074
|
+
/**
|
|
4075
|
+
* Stop reporting, leaving nothing stranded and nothing scheduled.
|
|
4076
|
+
*
|
|
4077
|
+
* Three things can be outstanding, and each needs an answer:
|
|
4078
|
+
*
|
|
4079
|
+
* - a batch waiting on a retry timer — it holds the only send slot, so clearing the timer alone would
|
|
4080
|
+
* leave it neither delivered nor counted. It gets one final attempt, with no retry behind it.
|
|
4081
|
+
* - a request in flight — it is aborted, and its completion drains what is left rather than starting
|
|
4082
|
+
* open-ended work.
|
|
4083
|
+
* - events still queued — drained a batch at a time, each attempted once.
|
|
4084
|
+
*
|
|
4085
|
+
* Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends
|
|
4086
|
+
* up delivered, refused or dropped, and none simply disappears.
|
|
4087
|
+
*
|
|
4088
|
+
* The drain is asynchronous, so this returns a promise that settles when nothing is outstanding —
|
|
4089
|
+
* which a host shutting down can await instead of racing the last batch against process exit. It is
|
|
4090
|
+
* best-effort and bounded: a runtime that terminates the process regardless, or a drain slower than
|
|
4091
|
+
* `STOP_BUDGET_MS`, still ends the wait. Ignoring the promise behaves exactly as before.
|
|
4092
|
+
*/
|
|
3164
4093
|
stop() {
|
|
4094
|
+
if (stopped) return drainPromise ?? Promise.resolve();
|
|
3165
4095
|
stopped = true;
|
|
3166
|
-
|
|
4096
|
+
drainPromise = new Promise((resolve) => {
|
|
4097
|
+
drainResolve = resolve;
|
|
4098
|
+
});
|
|
4099
|
+
budgetTimer = unattended(setTimeout(() => {
|
|
4100
|
+
budgetTimer = null;
|
|
4101
|
+
terminate();
|
|
4102
|
+
}, STOP_BUDGET_MS));
|
|
4103
|
+
if (timer) {
|
|
4104
|
+
clearTimeout(timer);
|
|
4105
|
+
timer = null;
|
|
4106
|
+
}
|
|
4107
|
+
draining = true;
|
|
4108
|
+
if (retryTimer) {
|
|
4109
|
+
clearTimeout(retryTimer);
|
|
4110
|
+
retryTimer = null;
|
|
4111
|
+
void attempt();
|
|
4112
|
+
return drainPromise;
|
|
4113
|
+
}
|
|
4114
|
+
if (sending) {
|
|
4115
|
+
inFlight?.controller?.abort();
|
|
4116
|
+
return drainPromise;
|
|
4117
|
+
}
|
|
4118
|
+
flushRequested = true;
|
|
4119
|
+
kick();
|
|
4120
|
+
return drainPromise;
|
|
3167
4121
|
},
|
|
3168
4122
|
/**
|
|
3169
4123
|
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
@@ -3182,12 +4136,70 @@ function createDetectionReporter(opts) {
|
|
|
3182
4136
|
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
3183
4137
|
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
3184
4138
|
*/
|
|
3185
|
-
health: () => ({
|
|
4139
|
+
health: () => ({
|
|
4140
|
+
sent,
|
|
4141
|
+
delivered,
|
|
4142
|
+
failed,
|
|
4143
|
+
dropped: droppedTotal + dropped,
|
|
4144
|
+
// Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever
|
|
4145
|
+
// succeeds on a second try is working, and is worth telling apart from one that never retries.
|
|
4146
|
+
retried,
|
|
4147
|
+
// Redeliveries the credential path made after a refused token, which are not backoff retries and
|
|
4148
|
+
// would otherwise appear nowhere: a rotated or revoked credential is worth seeing as itself.
|
|
4149
|
+
reauthorized,
|
|
4150
|
+
lastDeliveredAt,
|
|
4151
|
+
// Separate, because a capability announcement delivers no events. Reading zero here alongside a
|
|
4152
|
+
// non-zero `delivered` is a normal state, and so is the reverse.
|
|
4153
|
+
capability: {
|
|
4154
|
+
announced: capabilityAnnounced,
|
|
4155
|
+
acknowledged: capabilityAcknowledged,
|
|
4156
|
+
failed: capabilityFailed,
|
|
4157
|
+
retried: capabilityRetried,
|
|
4158
|
+
lastAcknowledgedAt: lastCapabilityAckAt
|
|
4159
|
+
}
|
|
4160
|
+
})
|
|
3186
4161
|
};
|
|
3187
4162
|
}
|
|
3188
4163
|
|
|
4164
|
+
// src/protect/reporting-state.js
|
|
4165
|
+
var REPORTING_STATES = Object.freeze([
|
|
4166
|
+
/** Enrolled, managed rules, credential present, not opted out. Events are sent. */
|
|
4167
|
+
"on",
|
|
4168
|
+
/** `PATCHSTACK_REPORT_DETECTIONS=0`. An explicit, per-deployment opt-out. */
|
|
4169
|
+
"disabled-by-config",
|
|
4170
|
+
/** `PATCHSTACK_TELEMETRY=0`. The broader switch, which covers this along with everything else. */
|
|
4171
|
+
"disabled-by-telemetry-opt-out",
|
|
4172
|
+
/** No site identity: a local or unenrolled install. Nothing to report against. */
|
|
4173
|
+
"not-enrolled",
|
|
4174
|
+
/**
|
|
4175
|
+
* A site identity, but the rules running are not the platform's — the caller's own bundle, or none.
|
|
4176
|
+
* There is no managed rule document to attribute a detection to, so a report would name a rule id the
|
|
4177
|
+
* platform never issued.
|
|
4178
|
+
*/
|
|
4179
|
+
"no-managed-rules",
|
|
4180
|
+
/** Enrolled with managed rules, but no credential resolved, so a report would be refused. */
|
|
4181
|
+
"unavailable-no-credential"
|
|
4182
|
+
]);
|
|
4183
|
+
function optedOut(value) {
|
|
4184
|
+
if (value === void 0 || value === null || value === "") return false;
|
|
4185
|
+
return /^(0|false|off|no)$/i.test(String(value));
|
|
4186
|
+
}
|
|
4187
|
+
function reportingState(input) {
|
|
4188
|
+
const env = input.env ?? (typeof process !== "undefined" ? process.env : void 0) ?? {};
|
|
4189
|
+
if (input.configOptOut === true) return { state: "disabled-by-config", reports: false };
|
|
4190
|
+
if (optedOut(env.PATCHSTACK_REPORT_DETECTIONS)) return { state: "disabled-by-config", reports: false };
|
|
4191
|
+
if (optedOut(env.PATCHSTACK_TELEMETRY)) return { state: "disabled-by-telemetry-opt-out", reports: false };
|
|
4192
|
+
const siteUuid = input.siteUuid;
|
|
4193
|
+
if (typeof siteUuid !== "string" || siteUuid === "") return { state: "not-enrolled", reports: false };
|
|
4194
|
+
if (input.hasCredential !== true) return { state: "unavailable-no-credential", reports: false };
|
|
4195
|
+
const managed = input.ruleOrigin === "api" || input.ruleOrigin === "cache";
|
|
4196
|
+
if (!managed) return { state: "no-managed-rules", reports: false };
|
|
4197
|
+
return { state: "on", reports: true };
|
|
4198
|
+
}
|
|
4199
|
+
|
|
3189
4200
|
// src/protect/firewall-log.js
|
|
3190
4201
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
4202
|
+
var STOP_BUDGET_MS2 = 5e3;
|
|
3191
4203
|
var DEFAULT_FLUSH_MS2 = 1e3;
|
|
3192
4204
|
var MAX_BATCH2 = 50;
|
|
3193
4205
|
var TOKEN_SKEW_MS2 = 6e4;
|
|
@@ -3228,9 +4240,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3228
4240
|
const creds = parseApiKey(opts.apiKey);
|
|
3229
4241
|
if (!creds) {
|
|
3230
4242
|
return { record() {
|
|
3231
|
-
}, flush()
|
|
3232
|
-
}, stop() {
|
|
3233
|
-
} };
|
|
4243
|
+
}, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
|
|
3234
4244
|
}
|
|
3235
4245
|
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
3236
4246
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -3239,9 +4249,13 @@ function createFirewallLogReporter(opts) {
|
|
|
3239
4249
|
let queue = [];
|
|
3240
4250
|
let timer = null;
|
|
3241
4251
|
let stopped = false;
|
|
4252
|
+
const outstanding = /* @__PURE__ */ new Set();
|
|
4253
|
+
let drainPromise = null;
|
|
4254
|
+
const lifetime = typeof AbortController === "function" ? new AbortController() : null;
|
|
4255
|
+
let ended = false;
|
|
3242
4256
|
let cachedToken = null;
|
|
3243
4257
|
let tokenInflight = null;
|
|
3244
|
-
const fetchAccessToken = async () => {
|
|
4258
|
+
const fetchAccessToken = async (signal) => {
|
|
3245
4259
|
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
|
|
3246
4260
|
return cachedToken.token;
|
|
3247
4261
|
}
|
|
@@ -3250,6 +4264,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3250
4264
|
try {
|
|
3251
4265
|
const res = await fetchImpl(`${apiBase}/oauth/token`, {
|
|
3252
4266
|
method: "POST",
|
|
4267
|
+
...signal ? { signal } : {},
|
|
3253
4268
|
headers: {
|
|
3254
4269
|
"Content-Type": "application/json",
|
|
3255
4270
|
Accept: "application/json",
|
|
@@ -3282,15 +4297,16 @@ function createFirewallLogReporter(opts) {
|
|
|
3282
4297
|
clearTimeout(timer);
|
|
3283
4298
|
timer = null;
|
|
3284
4299
|
}
|
|
3285
|
-
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
4300
|
+
if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
|
|
3286
4301
|
const batch = queue.splice(0, MAX_BATCH2);
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
if (!token) return;
|
|
3290
|
-
const body = new URLSearchParams();
|
|
3291
|
-
body.set("type", "firewall");
|
|
3292
|
-
body.set("logs", JSON.stringify(batch));
|
|
4302
|
+
let entry;
|
|
4303
|
+
const send = (async () => {
|
|
3293
4304
|
try {
|
|
4305
|
+
const token = await fetchAccessToken(lifetime?.signal);
|
|
4306
|
+
if (!token || ended) return;
|
|
4307
|
+
const body = new URLSearchParams();
|
|
4308
|
+
body.set("type", "firewall");
|
|
4309
|
+
body.set("logs", JSON.stringify(batch));
|
|
3294
4310
|
const p = fetchImpl(`${apiBase}/api/logs/log`, {
|
|
3295
4311
|
method: "POST",
|
|
3296
4312
|
headers: {
|
|
@@ -3300,13 +4316,20 @@ function createFirewallLogReporter(opts) {
|
|
|
3300
4316
|
"User-Agent": "@patchstack/connect",
|
|
3301
4317
|
...sourceHost ? { "Source-Host": sourceHost } : {}
|
|
3302
4318
|
},
|
|
3303
|
-
body
|
|
4319
|
+
body,
|
|
4320
|
+
// Both phases carry it, so a shutdown that runs out of time can end either one.
|
|
4321
|
+
...lifetime ? { signal: lifetime.signal } : {}
|
|
3304
4322
|
});
|
|
3305
|
-
if (p && typeof p.then === "function") p.catch(() => {
|
|
4323
|
+
if (p && typeof p.then === "function") await p.catch(() => {
|
|
3306
4324
|
});
|
|
3307
4325
|
} catch {
|
|
4326
|
+
} finally {
|
|
4327
|
+
outstanding.delete(entry);
|
|
3308
4328
|
}
|
|
3309
4329
|
})();
|
|
4330
|
+
entry = send;
|
|
4331
|
+
outstanding.add(send);
|
|
4332
|
+
return send;
|
|
3310
4333
|
};
|
|
3311
4334
|
return {
|
|
3312
4335
|
/**
|
|
@@ -3337,9 +4360,50 @@ function createFirewallLogReporter(opts) {
|
|
|
3337
4360
|
if (!timer) timer = setTimeout(flush, flushMs);
|
|
3338
4361
|
},
|
|
3339
4362
|
flush,
|
|
4363
|
+
/**
|
|
4364
|
+
* Stop, and hand back a wait for what was outstanding.
|
|
4365
|
+
*
|
|
4366
|
+
* One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
|
|
4367
|
+
* as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
|
|
4368
|
+
* spinning would be worse than leaving the records where they are.
|
|
4369
|
+
*/
|
|
3340
4370
|
stop() {
|
|
4371
|
+
if (drainPromise) return drainPromise;
|
|
3341
4372
|
stopped = true;
|
|
3342
|
-
|
|
4373
|
+
if (timer) {
|
|
4374
|
+
clearTimeout(timer);
|
|
4375
|
+
timer = null;
|
|
4376
|
+
}
|
|
4377
|
+
const terminate = () => {
|
|
4378
|
+
ended = true;
|
|
4379
|
+
lifetime?.abort();
|
|
4380
|
+
queue = [];
|
|
4381
|
+
outstanding.clear();
|
|
4382
|
+
};
|
|
4383
|
+
let budget = null;
|
|
4384
|
+
const spent = new Promise((resolve) => {
|
|
4385
|
+
budget = setTimeout(() => {
|
|
4386
|
+
terminate();
|
|
4387
|
+
resolve();
|
|
4388
|
+
}, STOP_BUDGET_MS2);
|
|
4389
|
+
if (typeof budget.unref === "function") budget.unref();
|
|
4390
|
+
});
|
|
4391
|
+
const work = (async () => {
|
|
4392
|
+
while (!ended) {
|
|
4393
|
+
if (outstanding.size > 0) {
|
|
4394
|
+
await Promise.all([...outstanding]);
|
|
4395
|
+
continue;
|
|
4396
|
+
}
|
|
4397
|
+
if (queue.length === 0) break;
|
|
4398
|
+
const before = queue.length;
|
|
4399
|
+
await flush();
|
|
4400
|
+
if (queue.length >= before) break;
|
|
4401
|
+
}
|
|
4402
|
+
})();
|
|
4403
|
+
drainPromise = Promise.race([work, spent]).then(() => {
|
|
4404
|
+
if (budget) clearTimeout(budget);
|
|
4405
|
+
});
|
|
4406
|
+
return drainPromise;
|
|
3343
4407
|
}
|
|
3344
4408
|
};
|
|
3345
4409
|
}
|
|
@@ -3430,8 +4494,8 @@ async function createProtection(options = {}) {
|
|
|
3430
4494
|
fetchImpl: options.fetchImpl
|
|
3431
4495
|
}) : null;
|
|
3432
4496
|
let detections = null;
|
|
4497
|
+
const ruleIdentity = (rule) => ({ id: rule?.id, category: rule?.category });
|
|
3433
4498
|
const onDetect = (detection) => {
|
|
3434
|
-
notify(userOnDetect, detection, "onDetect");
|
|
3435
4499
|
if (detections) detections.record(detection);
|
|
3436
4500
|
if (firewallLog && detection?.mode === "block") {
|
|
3437
4501
|
firewallLog.record({
|
|
@@ -3442,6 +4506,12 @@ async function createProtection(options = {}) {
|
|
|
3442
4506
|
userAgent: detection.userAgent
|
|
3443
4507
|
});
|
|
3444
4508
|
}
|
|
4509
|
+
const { capture: _evidence, ...forCallback } = detection ?? {};
|
|
4510
|
+
notify(
|
|
4511
|
+
userOnDetect,
|
|
4512
|
+
{ ...forCallback, ...detection?.rule ? { rule: ruleIdentity(detection.rule) } : {} },
|
|
4513
|
+
"onDetect"
|
|
4514
|
+
);
|
|
3445
4515
|
};
|
|
3446
4516
|
const store = makeStore(options);
|
|
3447
4517
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
@@ -3451,29 +4521,58 @@ async function createProtection(options = {}) {
|
|
|
3451
4521
|
notify(onError, new Error(message), "onError");
|
|
3452
4522
|
console.warn(message);
|
|
3453
4523
|
}
|
|
3454
|
-
const
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
4524
|
+
const cachedOrigin = async () => {
|
|
4525
|
+
const prior = await store.read();
|
|
4526
|
+
if (prior?.bundle) return "cache";
|
|
4527
|
+
return options.rules ? "bundled" : "empty";
|
|
4528
|
+
};
|
|
4529
|
+
const stateFor = (origin) => reportingState({
|
|
4530
|
+
siteUuid: options.siteUuid,
|
|
4531
|
+
ruleOrigin: origin,
|
|
4532
|
+
hasCredential: Boolean(pulseAuth),
|
|
4533
|
+
configOptOut: options.reportDetections === false
|
|
4534
|
+
});
|
|
4535
|
+
const preFetchState = stateFor(await cachedOrigin()).state;
|
|
4536
|
+
const bundle = await resolveRules(options, store, {
|
|
4537
|
+
timeoutMs: bootTimeoutMs,
|
|
4538
|
+
pulseAuth,
|
|
4539
|
+
detectionState: preFetchState
|
|
4540
|
+
});
|
|
4541
|
+
let detectionReporting = "not-enrolled";
|
|
4542
|
+
let currentOrigin;
|
|
4543
|
+
const applyReportingState = async (origin) => {
|
|
4544
|
+
currentOrigin = origin;
|
|
4545
|
+
const next = stateFor(origin);
|
|
4546
|
+
const changed = next.state !== detectionReporting;
|
|
4547
|
+
detectionReporting = next.state;
|
|
4548
|
+
if (next.reports && !detections) {
|
|
3464
4549
|
detections = createDetectionReporter({
|
|
3465
4550
|
siteUuid: options.siteUuid,
|
|
3466
4551
|
baseUrl: options.pulseRulesUrl,
|
|
3467
4552
|
pulseAuth,
|
|
3468
|
-
// The bundle the guard is actually running, so a hit can be attributed to the rules that
|
|
3469
|
-
// it rather than to whatever is current when the report is read.
|
|
3470
|
-
// see the refresh tick below.
|
|
4553
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that
|
|
4554
|
+
// produced it rather than to whatever is current when the report is read.
|
|
3471
4555
|
rulesEtag: (await store.read())?.etag ?? null,
|
|
3472
4556
|
fetchImpl: options.fetchImpl,
|
|
3473
4557
|
flushMs: options.detectionFlushMs
|
|
3474
4558
|
});
|
|
4559
|
+
} else if (!next.reports && detections) {
|
|
4560
|
+
detections.stop();
|
|
4561
|
+
detections = void 0;
|
|
3475
4562
|
}
|
|
3476
|
-
|
|
4563
|
+
if (changed && next.state === "unavailable-no-credential") {
|
|
4564
|
+
const message = "Patchstack: this site is enrolled and running managed rules, but no API credential resolved, so no security event could be delivered. Reporting is off.";
|
|
4565
|
+
notify(onError, new Error(message), "onError");
|
|
4566
|
+
console.warn(message);
|
|
4567
|
+
}
|
|
4568
|
+
return next;
|
|
4569
|
+
};
|
|
4570
|
+
const applyAndAcknowledge = async (origin, declared) => {
|
|
4571
|
+
const settled = await applyReportingState(origin);
|
|
4572
|
+
if (settled.state !== declared && detections) detections.announce(settled.state);
|
|
4573
|
+
return settled;
|
|
4574
|
+
};
|
|
4575
|
+
await applyAndAcknowledge(bundle.source?.origin, preFetchState);
|
|
3477
4576
|
let mode = resolveMode(options, bundle);
|
|
3478
4577
|
let requestRules;
|
|
3479
4578
|
let responseRules;
|
|
@@ -3520,6 +4619,19 @@ async function createProtection(options = {}) {
|
|
|
3520
4619
|
};
|
|
3521
4620
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
3522
4621
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
4622
|
+
const planCache = createPlanCache();
|
|
4623
|
+
const evidenceFrom = (result) => {
|
|
4624
|
+
if (!detections || !result?.rule) return void 0;
|
|
4625
|
+
let entry;
|
|
4626
|
+
try {
|
|
4627
|
+
entry = planCache.for(result.rule);
|
|
4628
|
+
} catch (err) {
|
|
4629
|
+
notify(onError, err, "onError");
|
|
4630
|
+
return void 0;
|
|
4631
|
+
}
|
|
4632
|
+
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
4633
|
+
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
4634
|
+
};
|
|
3523
4635
|
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
3524
4636
|
if (!result || !result.blocked) return allow2();
|
|
3525
4637
|
const effectiveMode = ruleMode(result.rule);
|
|
@@ -3534,7 +4646,12 @@ async function createProtection(options = {}) {
|
|
|
3534
4646
|
method: ctx.method,
|
|
3535
4647
|
path: ctx.path,
|
|
3536
4648
|
ip: ctx.ip,
|
|
3537
|
-
|
|
4649
|
+
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
4650
|
+
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
4651
|
+
clientIpSource: ctx.clientIpSource,
|
|
4652
|
+
userAgent: ctx.userAgent,
|
|
4653
|
+
// Derived from the reading this decision was made on, before that reading goes out of scope.
|
|
4654
|
+
capture: evidenceFrom(result)
|
|
3538
4655
|
});
|
|
3539
4656
|
return effectiveMode === "block" ? block() : allow2();
|
|
3540
4657
|
};
|
|
@@ -3557,7 +4674,18 @@ async function createProtection(options = {}) {
|
|
|
3557
4674
|
}
|
|
3558
4675
|
if (!result.blocked) continue;
|
|
3559
4676
|
const responseMode = ruleMode(rule);
|
|
3560
|
-
onDetect({
|
|
4677
|
+
onDetect({
|
|
4678
|
+
phase: "response",
|
|
4679
|
+
mode: responseMode,
|
|
4680
|
+
category: rule.category,
|
|
4681
|
+
rule,
|
|
4682
|
+
message: result.message,
|
|
4683
|
+
...requestMetaFromContext(reqCtx),
|
|
4684
|
+
// A response detection names its capture policy like any other. Response sources are never
|
|
4685
|
+
// capturable, so a response-only rule reports a plan that permitted nothing — which is the
|
|
4686
|
+
// policy, and a different fact from a rule that found nothing.
|
|
4687
|
+
capture: evidenceFrom(result)
|
|
4688
|
+
});
|
|
3561
4689
|
if (responseMode !== "block") continue;
|
|
3562
4690
|
if (redactors && redactors.length) {
|
|
3563
4691
|
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
@@ -3594,17 +4722,88 @@ async function createProtection(options = {}) {
|
|
|
3594
4722
|
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
3595
4723
|
return { verdict: "redact", body, headers };
|
|
3596
4724
|
};
|
|
3597
|
-
const
|
|
4725
|
+
const shapeExpressRequest = (req) => {
|
|
4726
|
+
const client = resolveClientIp({
|
|
4727
|
+
peer: req?.socket?.remoteAddress,
|
|
4728
|
+
headers: req?.headers ?? {},
|
|
4729
|
+
trustedProxy: options.trustedProxy
|
|
4730
|
+
});
|
|
4731
|
+
return {
|
|
4732
|
+
client,
|
|
4733
|
+
shaped: {
|
|
4734
|
+
// Own copies of everything a rule can address. Listed rather than spread, so a field the engine
|
|
4735
|
+
// gains has to be added here deliberately instead of appearing to work by accident.
|
|
4736
|
+
// Through the same gate as the engine's own normalisation: this projection turns whatever it reads
|
|
4737
|
+
// into an OWN property, so reading a polluted prototype here would launder it into evidence that
|
|
4738
|
+
// no later own-property check could tell from the real thing.
|
|
4739
|
+
method: requestField(req, "method"),
|
|
4740
|
+
url: requestField(req, "url"),
|
|
4741
|
+
originalUrl: requestField(req, "originalUrl"),
|
|
4742
|
+
headers: requestField(req, "headers"),
|
|
4743
|
+
query: requestField(req, "query"),
|
|
4744
|
+
body: requestField(req, "body"),
|
|
4745
|
+
files: requestField(req, "files"),
|
|
4746
|
+
cookies: requestField(req, "cookies"),
|
|
4747
|
+
socket: requestField(req, "socket"),
|
|
4748
|
+
// The verbatim body, when a caller kept one. A `raw` rule reads it directly, and the engine
|
|
4749
|
+
// otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing
|
|
4750
|
+
// did not keep: a body that failed to parse at all, a duplicate key where only the last value
|
|
4751
|
+
// survives, or the exact bytes a signature was written against.
|
|
4752
|
+
//
|
|
4753
|
+
// Own property only. Evidence is what this request actually carried: a value reachable through a
|
|
4754
|
+
// polluted prototype is not, and materialising it here would turn it into evidence indistinguish-
|
|
4755
|
+
// able from real bytes — firing every raw rule that matches it. The adapters that capture real
|
|
4756
|
+
// bytes define `_rawBody` on the request object itself.
|
|
4757
|
+
...Object.hasOwn(req ?? {}, "_rawBody") ? { _rawBody: req._rawBody } : {},
|
|
4758
|
+
// The resolved address, and the resolution itself for the consumers downstream.
|
|
4759
|
+
ip: client.ip ?? "",
|
|
4760
|
+
_clientIp: client
|
|
4761
|
+
}
|
|
4762
|
+
};
|
|
4763
|
+
};
|
|
4764
|
+
const screenFetchRequest = async (request) => {
|
|
4765
|
+
let result;
|
|
4766
|
+
let shaped;
|
|
4767
|
+
try {
|
|
4768
|
+
shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
|
|
4769
|
+
result = engine.evaluate(shaped);
|
|
4770
|
+
} catch (err) {
|
|
4771
|
+
notify(onError, err, "onError");
|
|
4772
|
+
return { blocked: null, client: void 0 };
|
|
4773
|
+
}
|
|
4774
|
+
const blocked = decide(
|
|
4775
|
+
"request",
|
|
4776
|
+
result,
|
|
4777
|
+
() => blockResponse(result, request),
|
|
4778
|
+
() => null,
|
|
4779
|
+
requestMeta(shaped, request)
|
|
4780
|
+
);
|
|
4781
|
+
return { blocked, client: shaped?._clientIp };
|
|
4782
|
+
};
|
|
4783
|
+
const reqContextFromFetch = (request, client) => {
|
|
3598
4784
|
try {
|
|
3599
4785
|
const u = new URL(request.url);
|
|
3600
4786
|
const headers = headerObject(request.headers);
|
|
3601
4787
|
if (!headers.host) headers.host = u.host;
|
|
3602
|
-
|
|
4788
|
+
const resolved = client ?? { ip: null, source: "unavailable" };
|
|
4789
|
+
return {
|
|
4790
|
+
method: request.method,
|
|
4791
|
+
originalUrl: u.pathname + u.search,
|
|
4792
|
+
headers,
|
|
4793
|
+
ip: resolved.ip ?? "",
|
|
4794
|
+
_clientIp: resolved
|
|
4795
|
+
};
|
|
3603
4796
|
} catch {
|
|
3604
4797
|
return void 0;
|
|
3605
4798
|
}
|
|
3606
4799
|
};
|
|
3607
|
-
const reqContextFromNode = (req) => req ? {
|
|
4800
|
+
const reqContextFromNode = (req, client) => req ? {
|
|
4801
|
+
method: req.method,
|
|
4802
|
+
originalUrl: req.url,
|
|
4803
|
+
headers: req.headers || {},
|
|
4804
|
+
ip: client?.ip ?? "",
|
|
4805
|
+
_clientIp: client ?? { ip: null, source: "unavailable" }
|
|
4806
|
+
} : void 0;
|
|
3608
4807
|
const screenResp = async (response, reqCtx) => {
|
|
3609
4808
|
const read = await readTextResponse(response, screenCap);
|
|
3610
4809
|
if (read.skip) {
|
|
@@ -3731,7 +4930,23 @@ async function createProtection(options = {}) {
|
|
|
3731
4930
|
}
|
|
3732
4931
|
if (!result.blocked) return false;
|
|
3733
4932
|
const egressMode = ruleMode(result.rule);
|
|
3734
|
-
|
|
4933
|
+
let egressPath = null;
|
|
4934
|
+
try {
|
|
4935
|
+
const u = new URL(url);
|
|
4936
|
+
egressPath = u.pathname + u.search;
|
|
4937
|
+
} catch {
|
|
4938
|
+
egressPath = typeof url === "string" ? url : null;
|
|
4939
|
+
}
|
|
4940
|
+
onDetect({
|
|
4941
|
+
phase: "egress",
|
|
4942
|
+
mode: egressMode,
|
|
4943
|
+
category: result.rule?.category,
|
|
4944
|
+
rule: result.rule,
|
|
4945
|
+
message: result.message,
|
|
4946
|
+
method: typeof method === "string" ? method : null,
|
|
4947
|
+
path: egressPath,
|
|
4948
|
+
capture: evidenceFrom(result)
|
|
4949
|
+
});
|
|
3735
4950
|
return egressMode === "block";
|
|
3736
4951
|
};
|
|
3737
4952
|
const protection = {
|
|
@@ -3752,28 +4967,26 @@ async function createProtection(options = {}) {
|
|
|
3752
4967
|
},
|
|
3753
4968
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
3754
4969
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
3755
|
-
|
|
4970
|
+
// A standalone response screen with no request phase of its own — the Supabase guard's forwarded
|
|
4971
|
+
// upstream response. It resolves once here, which is the only resolution for this call.
|
|
4972
|
+
screenResponse: (response, request) => screenResp(
|
|
4973
|
+
response,
|
|
4974
|
+
request ? reqContextFromFetch(
|
|
4975
|
+
request,
|
|
4976
|
+
resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
|
|
4977
|
+
) : void 0
|
|
4978
|
+
),
|
|
3756
4979
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
3757
4980
|
fetchGuard() {
|
|
3758
|
-
return async (request) =>
|
|
3759
|
-
let result;
|
|
3760
|
-
try {
|
|
3761
|
-
result = engine.evaluate(await fromFetchRequest(request));
|
|
3762
|
-
} catch (err) {
|
|
3763
|
-
notify(onError, err, "onError");
|
|
3764
|
-
return null;
|
|
3765
|
-
}
|
|
3766
|
-
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
3767
|
-
};
|
|
4981
|
+
return async (request) => (await screenFetchRequest(request)).blocked;
|
|
3768
4982
|
},
|
|
3769
4983
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
3770
4984
|
fetch(handler) {
|
|
3771
|
-
const guard = protection.fetchGuard();
|
|
3772
4985
|
return async (request, ...rest) => {
|
|
3773
|
-
const blocked = await
|
|
4986
|
+
const { blocked, client } = await screenFetchRequest(request);
|
|
3774
4987
|
if (blocked) return blocked;
|
|
3775
4988
|
const response = await handler(request, ...rest);
|
|
3776
|
-
return screenResp(response, reqContextFromFetch(request));
|
|
4989
|
+
return screenResp(response, reqContextFromFetch(request, client));
|
|
3777
4990
|
};
|
|
3778
4991
|
},
|
|
3779
4992
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
@@ -3781,11 +4994,12 @@ async function createProtection(options = {}) {
|
|
|
3781
4994
|
express(exprOptions = {}) {
|
|
3782
4995
|
return (req, res, next) => {
|
|
3783
4996
|
let result;
|
|
4997
|
+
const { shaped, client } = shapeExpressRequest(req);
|
|
3784
4998
|
try {
|
|
3785
|
-
result = engine.evaluate(
|
|
4999
|
+
result = engine.evaluate(shaped);
|
|
3786
5000
|
} catch (err) {
|
|
3787
5001
|
notify(onError, err, "onError");
|
|
3788
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
5002
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
3789
5003
|
return next();
|
|
3790
5004
|
}
|
|
3791
5005
|
decide(
|
|
@@ -3799,10 +5013,10 @@ async function createProtection(options = {}) {
|
|
|
3799
5013
|
}
|
|
3800
5014
|
},
|
|
3801
5015
|
() => {
|
|
3802
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
5016
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
3803
5017
|
next();
|
|
3804
5018
|
},
|
|
3805
|
-
nodeRequestMeta(req)
|
|
5019
|
+
nodeRequestMeta(req, client)
|
|
3806
5020
|
);
|
|
3807
5021
|
};
|
|
3808
5022
|
},
|
|
@@ -3841,7 +5055,7 @@ async function createProtection(options = {}) {
|
|
|
3841
5055
|
let shaped;
|
|
3842
5056
|
let result;
|
|
3843
5057
|
try {
|
|
3844
|
-
shaped = fromNodeRequest(req, rawBody);
|
|
5058
|
+
shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
|
|
3845
5059
|
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
3846
5060
|
result = engine.evaluate(shaped);
|
|
3847
5061
|
} catch (err) {
|
|
@@ -3863,10 +5077,10 @@ async function createProtection(options = {}) {
|
|
|
3863
5077
|
},
|
|
3864
5078
|
() => {
|
|
3865
5079
|
if (req.body === void 0) req.body = shaped.body;
|
|
3866
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
5080
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
3867
5081
|
next();
|
|
3868
5082
|
},
|
|
3869
|
-
nodeRequestMeta(req)
|
|
5083
|
+
nodeRequestMeta(req, shaped?._clientIp)
|
|
3870
5084
|
);
|
|
3871
5085
|
}
|
|
3872
5086
|
}
|
|
@@ -3902,9 +5116,15 @@ async function createProtection(options = {}) {
|
|
|
3902
5116
|
notify(onError, err, "onError");
|
|
3903
5117
|
}
|
|
3904
5118
|
}
|
|
3905
|
-
const
|
|
5119
|
+
const declaredState = stateFor(currentOrigin).state;
|
|
5120
|
+
const next = await resolveRules(options, store, {
|
|
5121
|
+
timeoutMs: options.refreshTimeoutMs,
|
|
5122
|
+
pulseAuth,
|
|
5123
|
+
detectionState: declaredState
|
|
5124
|
+
});
|
|
3906
5125
|
mode = resolveMode(options, next);
|
|
3907
5126
|
applyBundle(next);
|
|
5127
|
+
await applyAndAcknowledge(next.source?.origin, declaredState);
|
|
3908
5128
|
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
3909
5129
|
return next.source ?? { ok: true };
|
|
3910
5130
|
};
|
|
@@ -3915,12 +5135,20 @@ async function createProtection(options = {}) {
|
|
|
3915
5135
|
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
3916
5136
|
protection.stop = () => {
|
|
3917
5137
|
loop?.stop();
|
|
3918
|
-
firewallLog?.stop()
|
|
3919
|
-
|
|
5138
|
+
const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
|
|
5139
|
+
(wait) => wait && typeof wait.then === "function"
|
|
5140
|
+
);
|
|
5141
|
+
return Promise.all(outstanding).then(() => void 0);
|
|
3920
5142
|
};
|
|
3921
5143
|
protection.stopRefresh = protection.stop;
|
|
3922
|
-
protection
|
|
3923
|
-
|
|
5144
|
+
Object.defineProperty(protection, "detectionReporting", {
|
|
5145
|
+
get: () => detectionReporting,
|
|
5146
|
+
enumerable: true
|
|
5147
|
+
});
|
|
5148
|
+
Object.defineProperty(protection, "detectionHealth", {
|
|
5149
|
+
get: () => detections ? () => detections.health() : void 0,
|
|
5150
|
+
enumerable: true
|
|
5151
|
+
});
|
|
3924
5152
|
return protection;
|
|
3925
5153
|
}
|
|
3926
5154
|
function resolveMode(options, bundle) {
|
|
@@ -4326,30 +5554,48 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
4326
5554
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
4327
5555
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
4328
5556
|
}
|
|
4329
|
-
function
|
|
4330
|
-
if (!
|
|
4331
|
-
|
|
4332
|
-
try {
|
|
4333
|
-
path = new URL(request.url).pathname;
|
|
4334
|
-
} catch {
|
|
4335
|
-
path = typeof request.url === "string" ? request.url : null;
|
|
4336
|
-
}
|
|
5557
|
+
function requestMetaFromContext(reqCtx) {
|
|
5558
|
+
if (!reqCtx) return {};
|
|
5559
|
+
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
4337
5560
|
return {
|
|
4338
|
-
method:
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
5561
|
+
method: reqCtx.method ?? null,
|
|
5562
|
+
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
5563
|
+
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
5564
|
+
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
5565
|
+
ip: client.ip,
|
|
5566
|
+
clientIpSource: client.source,
|
|
5567
|
+
userAgent: reqCtx.headers?.["user-agent"] ?? null
|
|
4342
5568
|
};
|
|
4343
5569
|
}
|
|
4344
|
-
function
|
|
5570
|
+
function requestMeta(shaped, request) {
|
|
5571
|
+
const client = shaped?._clientIp ?? { ip: null, source: "unavailable" };
|
|
5572
|
+
let path = null;
|
|
5573
|
+
let method = null;
|
|
5574
|
+
let userAgent = null;
|
|
5575
|
+
if (request) {
|
|
5576
|
+
try {
|
|
5577
|
+
const u = new URL(request.url);
|
|
5578
|
+
path = u.pathname + u.search;
|
|
5579
|
+
} catch {
|
|
5580
|
+
path = typeof request.url === "string" ? request.url : null;
|
|
5581
|
+
}
|
|
5582
|
+
method = request.method ?? null;
|
|
5583
|
+
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
5584
|
+
}
|
|
5585
|
+
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
5586
|
+
}
|
|
5587
|
+
function nodeRequestMeta(req, client) {
|
|
4345
5588
|
if (!req) return {};
|
|
4346
5589
|
const headers = req.headers ?? {};
|
|
4347
5590
|
const ua = headers["user-agent"] ?? headers["User-Agent"];
|
|
4348
|
-
const
|
|
5591
|
+
const resolved = client ?? { ip: null, source: "unavailable" };
|
|
4349
5592
|
return {
|
|
4350
5593
|
method: req.method ?? null,
|
|
4351
5594
|
path: req.originalUrl || req.url || null,
|
|
4352
|
-
|
|
5595
|
+
// Never `req.ip`: under Express's `trust proxy` that is header-derived by a policy this guard has not
|
|
5596
|
+
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
5597
|
+
ip: resolved.ip,
|
|
5598
|
+
clientIpSource: resolved.source,
|
|
4353
5599
|
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
4354
5600
|
};
|
|
4355
5601
|
}
|