@patchstack/connect 0.3.23 → 0.3.24
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 +16 -7
- package/README.md +22 -2
- package/dist/chunk-MJOTFUDE.js +87 -0
- package/dist/chunk-MJOTFUDE.js.map +1 -0
- package/dist/cli.js +2857 -132
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +92 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +941 -221
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +7 -0
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +826 -195
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZIFRZF25.js → refresh-manifest-MUWVIPFR.js} +9 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-ZIFRZF25.js.map +0 -1
|
@@ -0,0 +1,3530 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
8
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
19
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
20
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
21
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
22
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
23
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
24
|
+
mod
|
|
25
|
+
));
|
|
26
|
+
|
|
27
|
+
// ps-edge:ps-edge-stub
|
|
28
|
+
var require_ps_edge_stub = __commonJS({
|
|
29
|
+
"ps-edge:ps-edge-stub"() {
|
|
30
|
+
throw new Error("[patchstack] this module is Node-only and unavailable on an edge runtime");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// src/protect/safe-origin.js
|
|
35
|
+
function isSafeOrigin(value) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(value);
|
|
38
|
+
if (u.protocol === "https:") return true;
|
|
39
|
+
return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function safeBaseUrl(candidate, fallback, label) {
|
|
45
|
+
if (typeof candidate !== "string" || candidate === "") return fallback;
|
|
46
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
47
|
+
warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
var warned = /* @__PURE__ */ new Set();
|
|
51
|
+
function warnOnce(key, message) {
|
|
52
|
+
if (warned.has(key)) return;
|
|
53
|
+
warned.add(key);
|
|
54
|
+
console.warn(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/protect/engine/client.js
|
|
58
|
+
var DEFAULT_BASE_URL = "https://api.patchstack.com";
|
|
59
|
+
var DEFAULT_CACHE_TTL = 3e5;
|
|
60
|
+
var JITTER_FRACTION = 0.1;
|
|
61
|
+
var PatchstackRuleClient = class {
|
|
62
|
+
#token;
|
|
63
|
+
#baseUrl;
|
|
64
|
+
#cacheTtl;
|
|
65
|
+
#cache = null;
|
|
66
|
+
#cacheTime = null;
|
|
67
|
+
#ttlEffective = 0;
|
|
68
|
+
#etag;
|
|
69
|
+
#timeoutMs;
|
|
70
|
+
constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
|
|
71
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
72
|
+
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
|
|
73
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_WAF_API_URL, DEFAULT_BASE_URL, "rule endpoint");
|
|
74
|
+
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
|
|
75
|
+
this.#etag = etag ?? null;
|
|
76
|
+
if (!this.#token) {
|
|
77
|
+
throw new Error("Patchstack WAF token is required. Pass { token } or set PATCHSTACK_WAF_TOKEN env var.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async getRules() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
|
|
83
|
+
return this.#cache;
|
|
84
|
+
}
|
|
85
|
+
const url = `${this.#baseUrl}/api/get-rules/3`;
|
|
86
|
+
try {
|
|
87
|
+
const headers = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"Authorization": `Bearer ${this.#token}`,
|
|
90
|
+
"LicenseID": "1"
|
|
91
|
+
// Hard-coded, is never actually used but needed by the API
|
|
92
|
+
};
|
|
93
|
+
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers,
|
|
97
|
+
body: JSON.stringify({}),
|
|
98
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
99
|
+
});
|
|
100
|
+
if (response.status === 304) {
|
|
101
|
+
this.#touch(now);
|
|
102
|
+
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error: `API returned ${response.status}: ${response.statusText}`,
|
|
108
|
+
firewall: [],
|
|
109
|
+
whitelists: [],
|
|
110
|
+
whitelist_keys: {}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
if (!data || !Array.isArray(data.firewall)) {
|
|
115
|
+
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
116
|
+
}
|
|
117
|
+
const result = {
|
|
118
|
+
success: true,
|
|
119
|
+
notModified: false,
|
|
120
|
+
etag: response.headers?.get?.("etag") ?? null,
|
|
121
|
+
firewall: data.firewall,
|
|
122
|
+
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
123
|
+
whitelist_keys: data.whitelist_keys ?? {}
|
|
124
|
+
};
|
|
125
|
+
this.#cache = result;
|
|
126
|
+
this.#etag = result.etag;
|
|
127
|
+
this.#touch(now);
|
|
128
|
+
return result;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
error: err.name === "TimeoutError" ? "Request timed out" : err.message,
|
|
133
|
+
firewall: [],
|
|
134
|
+
whitelists: [],
|
|
135
|
+
whitelist_keys: {}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#touch(now) {
|
|
140
|
+
this.#cacheTime = now;
|
|
141
|
+
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION);
|
|
142
|
+
}
|
|
143
|
+
clearCache() {
|
|
144
|
+
this.#cache = null;
|
|
145
|
+
this.#cacheTime = null;
|
|
146
|
+
this.#etag = null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/protect/engine/request.js
|
|
151
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
152
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
153
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
154
|
+
function base64DecodeUtf8(value) {
|
|
155
|
+
const str = String(value);
|
|
156
|
+
if (typeof Buffer !== "undefined") {
|
|
157
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
158
|
+
}
|
|
159
|
+
const binary = atob(str);
|
|
160
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
161
|
+
return new TextDecoder().decode(bytes);
|
|
162
|
+
}
|
|
163
|
+
var RequestResolver = class {
|
|
164
|
+
#req;
|
|
165
|
+
#cookies;
|
|
166
|
+
constructor(req) {
|
|
167
|
+
this.#req = req;
|
|
168
|
+
this.#cookies = null;
|
|
169
|
+
}
|
|
170
|
+
resolve(parameter) {
|
|
171
|
+
if (!parameter || parameter === "false") {
|
|
172
|
+
return [null];
|
|
173
|
+
}
|
|
174
|
+
if (parameter === "rules") {
|
|
175
|
+
return [null];
|
|
176
|
+
}
|
|
177
|
+
if (parameter === "raw") {
|
|
178
|
+
return this.#resolveRaw();
|
|
179
|
+
}
|
|
180
|
+
if (parameter === "all") {
|
|
181
|
+
return this.#resolveAll();
|
|
182
|
+
}
|
|
183
|
+
const dotIndex = parameter.indexOf(".");
|
|
184
|
+
if (dotIndex === -1) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const source = parameter.substring(0, dotIndex);
|
|
188
|
+
const key = parameter.substring(dotIndex + 1);
|
|
189
|
+
switch (source) {
|
|
190
|
+
case "get":
|
|
191
|
+
return this.#resolveGet(key);
|
|
192
|
+
case "post":
|
|
193
|
+
return this.#resolvePost(key);
|
|
194
|
+
case "request":
|
|
195
|
+
return this.#resolveRequest(key);
|
|
196
|
+
case "cookie":
|
|
197
|
+
return this.#resolveCookie(key);
|
|
198
|
+
case "server":
|
|
199
|
+
return this.#resolveServer(key);
|
|
200
|
+
case "files":
|
|
201
|
+
return this.#resolveFiles(key);
|
|
202
|
+
case "response":
|
|
203
|
+
return this.#resolveResponse(key);
|
|
204
|
+
case "egress":
|
|
205
|
+
return this.#resolveEgress(key);
|
|
206
|
+
default:
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
|
|
211
|
+
// what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
|
|
212
|
+
#resolveResponse(key) {
|
|
213
|
+
const resp = this.#req._response;
|
|
214
|
+
if (!resp) {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
if (key === "status") {
|
|
218
|
+
return resp.status !== void 0 ? [String(resp.status)] : [];
|
|
219
|
+
}
|
|
220
|
+
if (key === "body") {
|
|
221
|
+
return resp.body != null && resp.body !== "" ? [resp.body] : [];
|
|
222
|
+
}
|
|
223
|
+
if (key === "headers") {
|
|
224
|
+
const headers = resp.headers ?? {};
|
|
225
|
+
return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
|
|
226
|
+
}
|
|
227
|
+
if (key.startsWith("header.")) {
|
|
228
|
+
const name = key.slice("header.".length).toLowerCase();
|
|
229
|
+
const value = (resp.headers ?? {})[name];
|
|
230
|
+
return value !== void 0 ? [value] : [];
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
// Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
|
|
235
|
+
// OUTBOUND request the app is about to make — SSRF at the egress boundary.
|
|
236
|
+
#resolveEgress(key) {
|
|
237
|
+
const eg = this.#req._egress;
|
|
238
|
+
if (!eg) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
if (key === "url") return eg.url ? [eg.url] : [];
|
|
242
|
+
if (key === "host") return eg.host ? [eg.host] : [];
|
|
243
|
+
if (key === "method") return eg.method ? [eg.method] : [];
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
applyMutations(mutations, value) {
|
|
247
|
+
if (!mutations || !Array.isArray(mutations)) {
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
let result = value;
|
|
251
|
+
for (const mutation of mutations) {
|
|
252
|
+
result = this.#applyMutation(mutation, result);
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
#applyMutation(mutation, value) {
|
|
257
|
+
if (value === null || value === void 0) {
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
switch (mutation) {
|
|
261
|
+
case "base64_decode":
|
|
262
|
+
try {
|
|
263
|
+
return base64DecodeUtf8(value);
|
|
264
|
+
} catch {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
case "json_decode":
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(String(value));
|
|
270
|
+
} catch {
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
case "json_encode":
|
|
274
|
+
try {
|
|
275
|
+
return JSON.stringify(value);
|
|
276
|
+
} catch {
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
case "urldecode":
|
|
280
|
+
try {
|
|
281
|
+
return decodeURIComponent(String(value));
|
|
282
|
+
} catch {
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
case "intval":
|
|
286
|
+
return parseInt(String(value), 10) || 0;
|
|
287
|
+
case "getArrayValues":
|
|
288
|
+
if (typeof value === "object" && value !== null) {
|
|
289
|
+
return Object.values(value);
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
default:
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
#resolveGet(key) {
|
|
297
|
+
const query = this.#req.query ?? {};
|
|
298
|
+
if (key.endsWith("*")) {
|
|
299
|
+
return this.#resolveWildcard(query, key);
|
|
300
|
+
}
|
|
301
|
+
const value = this.#getNestedValue(query, key);
|
|
302
|
+
return value !== void 0 ? [value] : [];
|
|
303
|
+
}
|
|
304
|
+
#resolvePost(key) {
|
|
305
|
+
const body = this.#req.body ?? {};
|
|
306
|
+
if (key.endsWith("*")) {
|
|
307
|
+
return this.#resolveWildcard(body, key);
|
|
308
|
+
}
|
|
309
|
+
const value = this.#getNestedValue(body, key);
|
|
310
|
+
return value !== void 0 ? [value] : [];
|
|
311
|
+
}
|
|
312
|
+
#resolveRequest(key) {
|
|
313
|
+
const query = this.#req.query ?? {};
|
|
314
|
+
const body = this.#req.body ?? {};
|
|
315
|
+
const cookies = this.#parseCookies();
|
|
316
|
+
if (key.endsWith("*")) {
|
|
317
|
+
return [
|
|
318
|
+
...this.#resolveWildcard(query, key),
|
|
319
|
+
...this.#resolveWildcard(body, key),
|
|
320
|
+
...this.#resolveWildcard(cookies, key)
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? cookies[key];
|
|
324
|
+
return value !== void 0 ? [value] : [];
|
|
325
|
+
}
|
|
326
|
+
#resolveCookie(key) {
|
|
327
|
+
const cookies = this.#parseCookies();
|
|
328
|
+
if (key.endsWith("*")) {
|
|
329
|
+
return this.#resolveWildcard(cookies, key);
|
|
330
|
+
}
|
|
331
|
+
const value = cookies[key];
|
|
332
|
+
return value !== void 0 ? [value] : [];
|
|
333
|
+
}
|
|
334
|
+
#resolveServer(key) {
|
|
335
|
+
const req = this.#req;
|
|
336
|
+
switch (key) {
|
|
337
|
+
case "REQUEST_URI":
|
|
338
|
+
return [req.originalUrl ?? req.url ?? "/"];
|
|
339
|
+
case "REQUEST_METHOD":
|
|
340
|
+
return [req.method ?? "GET"];
|
|
341
|
+
case "HTTP_USER_AGENT":
|
|
342
|
+
return req.headers?.["user-agent"] ? [req.headers["user-agent"]] : [];
|
|
343
|
+
case "HTTP_REFERER":
|
|
344
|
+
return req.headers?.referer ? [req.headers.referer] : [];
|
|
345
|
+
case "HTTP_HOST":
|
|
346
|
+
return req.headers?.host ? [req.headers.host] : [];
|
|
347
|
+
case "REMOTE_ADDR":
|
|
348
|
+
case "ip":
|
|
349
|
+
return [req.ip ?? req.socket?.remoteAddress ?? ""];
|
|
350
|
+
case "CONTENT_TYPE":
|
|
351
|
+
return req.headers?.["content-type"] ? [req.headers["content-type"]] : [];
|
|
352
|
+
case "CONTENT_LENGTH":
|
|
353
|
+
return req.headers?.["content-length"] ? [req.headers["content-length"]] : [];
|
|
354
|
+
default: {
|
|
355
|
+
if (key.startsWith("HTTP_")) {
|
|
356
|
+
const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
|
|
357
|
+
const headers = req.headers;
|
|
358
|
+
if (headers === null || typeof headers !== "object") return [];
|
|
359
|
+
return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
|
|
360
|
+
}
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
#resolveFiles(key) {
|
|
366
|
+
const files = this.#req.files;
|
|
367
|
+
if (!files || typeof files !== "object") {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
const dot = key.lastIndexOf(".");
|
|
371
|
+
if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) {
|
|
372
|
+
const attr = key.slice(dot + 1);
|
|
373
|
+
const entry = files[key.slice(0, dot)];
|
|
374
|
+
const list = Array.isArray(entry) ? entry : [entry];
|
|
375
|
+
const out = [];
|
|
376
|
+
for (const f of list) {
|
|
377
|
+
const v = fileAttribute(f, attr);
|
|
378
|
+
if (v !== void 0 && v !== "") out.push(v);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
const filenamesOf = (entry) => Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)];
|
|
383
|
+
if (key.endsWith("*")) {
|
|
384
|
+
const prefix = key.slice(0, -1);
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const [k, entry] of Object.entries(files)) {
|
|
387
|
+
if (k.startsWith(prefix)) out.push(...filenamesOf(entry));
|
|
388
|
+
}
|
|
389
|
+
return out.filter((v) => v !== void 0);
|
|
390
|
+
}
|
|
391
|
+
if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
|
|
392
|
+
return filenamesOf(files[key]).filter((v) => v !== void 0);
|
|
393
|
+
}
|
|
394
|
+
#resolveRaw() {
|
|
395
|
+
if (typeof this.#req._rawBody === "string") {
|
|
396
|
+
return this.#req._rawBody ? [this.#req._rawBody] : [];
|
|
397
|
+
}
|
|
398
|
+
const body = this.#req.body;
|
|
399
|
+
if (body === void 0 || body === null) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
if (typeof body === "string") {
|
|
403
|
+
return [body];
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
return [JSON.stringify(body)];
|
|
407
|
+
} catch {
|
|
408
|
+
return [String(body)];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
#resolveAll() {
|
|
412
|
+
const parts = [];
|
|
413
|
+
const uri = this.#req.originalUrl ?? this.#req.url ?? "/";
|
|
414
|
+
parts.push(uri);
|
|
415
|
+
const queryString = uri.includes("?") ? uri.split("?")[1] : "";
|
|
416
|
+
if (queryString) {
|
|
417
|
+
parts.push(queryString);
|
|
418
|
+
}
|
|
419
|
+
const body = this.#req.body;
|
|
420
|
+
if (body) {
|
|
421
|
+
parts.push(typeof body === "string" ? body : JSON.stringify(body));
|
|
422
|
+
}
|
|
423
|
+
if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
|
|
424
|
+
parts.push(this.#req._rawBody);
|
|
425
|
+
}
|
|
426
|
+
const headers = this.#req.headers ?? {};
|
|
427
|
+
const excludedHeaders = /* @__PURE__ */ new Set([
|
|
428
|
+
"host",
|
|
429
|
+
"connection",
|
|
430
|
+
"cache-control",
|
|
431
|
+
"accept",
|
|
432
|
+
"accept-encoding",
|
|
433
|
+
"accept-language",
|
|
434
|
+
"priority",
|
|
435
|
+
"sec-ch-ua",
|
|
436
|
+
"sec-ch-ua-mobile",
|
|
437
|
+
"sec-ch-ua-platform",
|
|
438
|
+
"sec-fetch-dest",
|
|
439
|
+
"sec-fetch-mode",
|
|
440
|
+
"sec-fetch-site",
|
|
441
|
+
"sec-fetch-user",
|
|
442
|
+
"upgrade-insecure-requests"
|
|
443
|
+
]);
|
|
444
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
445
|
+
if (!excludedHeaders.has(name)) {
|
|
446
|
+
parts.push(`${name}: ${value}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const cookies = this.#parseCookies();
|
|
450
|
+
const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
|
|
451
|
+
if (cookieStr) {
|
|
452
|
+
parts.push(cookieStr);
|
|
453
|
+
}
|
|
454
|
+
return [parts.join(" ")];
|
|
455
|
+
}
|
|
456
|
+
#resolveWildcard(obj, pattern) {
|
|
457
|
+
if (typeof obj !== "object" || obj === null) {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
const prefix = pattern.slice(0, -1);
|
|
461
|
+
const values = [];
|
|
462
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
463
|
+
if (key.startsWith(prefix)) {
|
|
464
|
+
values.push(value);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return values;
|
|
468
|
+
}
|
|
469
|
+
#getNestedValue(obj, key) {
|
|
470
|
+
if (typeof obj !== "object" || obj === null) {
|
|
471
|
+
return void 0;
|
|
472
|
+
}
|
|
473
|
+
const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
|
|
474
|
+
if (own(obj, key)) {
|
|
475
|
+
return obj[key];
|
|
476
|
+
}
|
|
477
|
+
const parts = key.split(".");
|
|
478
|
+
let current = obj;
|
|
479
|
+
for (const part of parts) {
|
|
480
|
+
if (!own(current, part)) {
|
|
481
|
+
return void 0;
|
|
482
|
+
}
|
|
483
|
+
current = current[part];
|
|
484
|
+
}
|
|
485
|
+
return current;
|
|
486
|
+
}
|
|
487
|
+
#parseCookies() {
|
|
488
|
+
if (this.#cookies !== null) {
|
|
489
|
+
return this.#cookies;
|
|
490
|
+
}
|
|
491
|
+
if (this.#req.cookies) {
|
|
492
|
+
this.#cookies = this.#req.cookies;
|
|
493
|
+
return this.#cookies;
|
|
494
|
+
}
|
|
495
|
+
const header = this.#req.headers?.cookie;
|
|
496
|
+
if (!header) {
|
|
497
|
+
this.#cookies = {};
|
|
498
|
+
return this.#cookies;
|
|
499
|
+
}
|
|
500
|
+
const cookies = {};
|
|
501
|
+
for (const pair of header.split(";")) {
|
|
502
|
+
const eqIndex = pair.indexOf("=");
|
|
503
|
+
if (eqIndex === -1) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const name = pair.substring(0, eqIndex).trim();
|
|
507
|
+
const value = pair.substring(eqIndex + 1).trim();
|
|
508
|
+
cookies[name] = value;
|
|
509
|
+
}
|
|
510
|
+
this.#cookies = cookies;
|
|
511
|
+
return this.#cookies;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// src/protect/engine/normalizer.js
|
|
516
|
+
var HTML_ENTITIES = {
|
|
517
|
+
"&": "&",
|
|
518
|
+
"<": "<",
|
|
519
|
+
">": ">",
|
|
520
|
+
""": '"',
|
|
521
|
+
"'": "'",
|
|
522
|
+
"'": "'",
|
|
523
|
+
" ": " ",
|
|
524
|
+
"'": "'",
|
|
525
|
+
""": '"',
|
|
526
|
+
"<": "<",
|
|
527
|
+
">": ">",
|
|
528
|
+
"&": "&",
|
|
529
|
+
""": '"',
|
|
530
|
+
"<": "<",
|
|
531
|
+
">": ">",
|
|
532
|
+
"&": "&"
|
|
533
|
+
};
|
|
534
|
+
var MAX_DECODE_ITERATIONS = 5;
|
|
535
|
+
function normalize(value, options = {}) {
|
|
536
|
+
if (typeof value !== "string") {
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
const opts = {
|
|
540
|
+
urlDecode: true,
|
|
541
|
+
htmlDecode: true,
|
|
542
|
+
sqlComments: true,
|
|
543
|
+
nullBytes: true,
|
|
544
|
+
whitespace: true,
|
|
545
|
+
...options
|
|
546
|
+
};
|
|
547
|
+
let result = value;
|
|
548
|
+
if (opts.nullBytes) {
|
|
549
|
+
result = removeNullBytes(result);
|
|
550
|
+
}
|
|
551
|
+
if (opts.urlDecode) {
|
|
552
|
+
result = urlDecode(result);
|
|
553
|
+
}
|
|
554
|
+
if (opts.htmlDecode) {
|
|
555
|
+
result = htmlEntityDecode(result);
|
|
556
|
+
}
|
|
557
|
+
if (opts.sqlComments) {
|
|
558
|
+
result = removeSqlComments(result);
|
|
559
|
+
}
|
|
560
|
+
if (opts.whitespace) {
|
|
561
|
+
result = normalizeWhitespace(result);
|
|
562
|
+
}
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
function urlDecode(value) {
|
|
566
|
+
if (typeof value !== "string") {
|
|
567
|
+
return value;
|
|
568
|
+
}
|
|
569
|
+
let result = value;
|
|
570
|
+
let previous = "";
|
|
571
|
+
let iterations = 0;
|
|
572
|
+
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
573
|
+
previous = result;
|
|
574
|
+
iterations++;
|
|
575
|
+
try {
|
|
576
|
+
result = decodeURIComponent(result);
|
|
577
|
+
} catch {
|
|
578
|
+
result = safeUrlDecode(result);
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
function safeUrlDecode(value) {
|
|
585
|
+
return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
|
|
586
|
+
try {
|
|
587
|
+
return String.fromCharCode(parseInt(hex, 16));
|
|
588
|
+
} catch {
|
|
589
|
+
return match;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
function htmlEntityDecode(value) {
|
|
594
|
+
if (typeof value !== "string") {
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
597
|
+
let result = value;
|
|
598
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
599
|
+
result = result.split(entity).join(char);
|
|
600
|
+
}
|
|
601
|
+
result = result.replace(/&#(\d+);/g, (match, code) => {
|
|
602
|
+
const num = parseInt(code, 10);
|
|
603
|
+
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
604
|
+
});
|
|
605
|
+
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
|
|
606
|
+
const num = parseInt(hex, 16);
|
|
607
|
+
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
608
|
+
});
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
function removeSqlComments(value) {
|
|
612
|
+
if (typeof value !== "string") {
|
|
613
|
+
return value;
|
|
614
|
+
}
|
|
615
|
+
let result = value;
|
|
616
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
617
|
+
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
function removeNullBytes(value) {
|
|
621
|
+
if (typeof value !== "string") {
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
let result = value.replace(/\x00/g, "");
|
|
625
|
+
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
function normalizeWhitespace(value) {
|
|
629
|
+
if (typeof value !== "string") {
|
|
630
|
+
return value;
|
|
631
|
+
}
|
|
632
|
+
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
633
|
+
result = result.replace(/ {2,}/g, " ");
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isRoot = true) {
|
|
637
|
+
if (body === null || body === void 0) {
|
|
638
|
+
return isRoot ? "" : "null";
|
|
639
|
+
}
|
|
640
|
+
if (typeof body === "string") {
|
|
641
|
+
return isRoot ? body : JSON.stringify(body);
|
|
642
|
+
}
|
|
643
|
+
if (typeof body !== "object") {
|
|
644
|
+
return String(body);
|
|
645
|
+
}
|
|
646
|
+
if (visited.has(body)) {
|
|
647
|
+
return "[Circular]";
|
|
648
|
+
}
|
|
649
|
+
visited.add(body);
|
|
650
|
+
if (Array.isArray(body)) {
|
|
651
|
+
const items = body.map((item) => serializeForRawDetection(item, visited, false));
|
|
652
|
+
return "[" + items.join(",") + "]";
|
|
653
|
+
}
|
|
654
|
+
const keys = Object.getOwnPropertyNames(body);
|
|
655
|
+
const parts = keys.map((key) => {
|
|
656
|
+
const val = serializeForRawDetection(body[key], visited, false);
|
|
657
|
+
return JSON.stringify(key) + ":" + val;
|
|
658
|
+
});
|
|
659
|
+
return "{" + parts.join(",") + "}";
|
|
660
|
+
}
|
|
661
|
+
function normalizeRequest(req, options = {}) {
|
|
662
|
+
const rawBody = typeof req._rawBody === "string" ? req._rawBody : serializeForRawDetection(req.body ?? null);
|
|
663
|
+
return {
|
|
664
|
+
query: normalizeObject(req.query || {}, options),
|
|
665
|
+
body: normalizeObject(req.body || {}, options),
|
|
666
|
+
headers: normalizeObject(req.headers || {}, options),
|
|
667
|
+
url: normalize(req.url || "", options),
|
|
668
|
+
originalUrl: normalize(req.originalUrl || req.url || "", options),
|
|
669
|
+
_rawBody: rawBody
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
var MAX_NORMALIZE_DEPTH = 200;
|
|
673
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
674
|
+
if (typeof value === "string") {
|
|
675
|
+
return normalize(value, options);
|
|
676
|
+
}
|
|
677
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
678
|
+
return value;
|
|
679
|
+
}
|
|
680
|
+
if (Array.isArray(value)) {
|
|
681
|
+
return value.map((item) => normalizeObject(item, options, depth + 1));
|
|
682
|
+
}
|
|
683
|
+
if (typeof value === "object" && value !== null) {
|
|
684
|
+
const result = {};
|
|
685
|
+
for (const [key, val] of Object.entries(value)) {
|
|
686
|
+
result[key] = normalizeObject(val, options, depth + 1);
|
|
687
|
+
}
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
return value;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/protect/engine/engine.js
|
|
694
|
+
var GRP = "(?:[^()]|\\([^()]*\\))*";
|
|
695
|
+
var REDOS_PATTERNS = [
|
|
696
|
+
/\([^)]*[+*}][^)]*\)\s*[+*]/,
|
|
697
|
+
/\([^)]*\|[^)]*\)\s*[+*]/,
|
|
698
|
+
new RegExp("\\(" + GRP + "[+*}]" + GRP + "\\)\\s*[+*]"),
|
|
699
|
+
// nested quantified subgroup
|
|
700
|
+
new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
|
|
701
|
+
// nested alternation under a quantifier
|
|
702
|
+
];
|
|
703
|
+
var warnedRejectedPatterns = /* @__PURE__ */ new Set();
|
|
704
|
+
function warnRejectedPatternOnce(pattern) {
|
|
705
|
+
const key = String(pattern);
|
|
706
|
+
if (warnedRejectedPatterns.has(key)) return;
|
|
707
|
+
warnedRejectedPatterns.add(key);
|
|
708
|
+
console.warn(
|
|
709
|
+
`[patchstack] rule_v2 regex pattern rejected (unsafe or invalid) \u2014 condition treated as no-match. The rule relying on it is NOT enforced: ${key}`
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
var MAX_PATTERN_LENGTH = 1e3;
|
|
713
|
+
function safeRegExp(pattern) {
|
|
714
|
+
if (!pattern) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
for (const dangerous of REDOS_PATTERNS) {
|
|
721
|
+
if (dangerous.test(pattern)) {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
|
|
726
|
+
if (!match) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
return new RegExp(match[1], match[2]);
|
|
731
|
+
} catch {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function ctypeResult(strValue, isClass, matchVal) {
|
|
736
|
+
if (strValue === "") {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
const expected = typeof matchVal === "boolean" ? matchVal : true;
|
|
740
|
+
return isClass === expected;
|
|
741
|
+
}
|
|
742
|
+
function walkLeaves(node, segments, cb) {
|
|
743
|
+
if (node === null || typeof node !== "object") return;
|
|
744
|
+
if (Array.isArray(node)) {
|
|
745
|
+
for (const el of node) walkLeaves(el, segments, cb);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const [head, ...rest] = segments;
|
|
749
|
+
if (head === void 0 || !Object.prototype.hasOwnProperty.call(node, head)) return;
|
|
750
|
+
if (rest.length === 0) {
|
|
751
|
+
const leaf = node[head];
|
|
752
|
+
if (Array.isArray(leaf)) {
|
|
753
|
+
for (let i = 0; i < leaf.length; i++) cb({ parent: leaf, key: i, value: leaf[i] });
|
|
754
|
+
} else {
|
|
755
|
+
cb({ parent: node, key: head, value: leaf });
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
walkLeaves(node[head], rest, cb);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
function arrayKeyValue(value, matchObj) {
|
|
762
|
+
if (!matchObj || !matchObj.match || value === null || typeof value !== "object") {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
const keys = Array.isArray(matchObj.key) ? matchObj.key : [matchObj.key];
|
|
766
|
+
const sub = matchObj.match;
|
|
767
|
+
for (const key of keys) {
|
|
768
|
+
let matched = false;
|
|
769
|
+
walkLeaves(value, String(key).split("."), ({ value: leaf }) => {
|
|
770
|
+
if (!matched && matchValue(sub.type, leaf, sub.value, sub)) matched = true;
|
|
771
|
+
});
|
|
772
|
+
if (matched) return true;
|
|
773
|
+
}
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
777
|
+
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
778
|
+
const out = [];
|
|
779
|
+
const stack = [[root, 0]];
|
|
780
|
+
let visited = 0;
|
|
781
|
+
while (stack.length) {
|
|
782
|
+
const [node, depth] = stack.pop();
|
|
783
|
+
if (node === null || node === void 0) continue;
|
|
784
|
+
if (typeof node !== "object") {
|
|
785
|
+
out.push(node);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
789
|
+
visited++;
|
|
790
|
+
if (Array.isArray(node)) {
|
|
791
|
+
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
792
|
+
} else {
|
|
793
|
+
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
var warnedKeys = /* @__PURE__ */ new Set();
|
|
799
|
+
function warnOnce2(key, message) {
|
|
800
|
+
if (warnedKeys.has(key)) return;
|
|
801
|
+
warnedKeys.add(key);
|
|
802
|
+
console.warn(message);
|
|
803
|
+
}
|
|
804
|
+
var warnedMatchTypes = /* @__PURE__ */ new Set();
|
|
805
|
+
function warnUnsupportedMatchType(type) {
|
|
806
|
+
if (type == null || warnedMatchTypes.has(type)) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
warnedMatchTypes.add(type);
|
|
810
|
+
console.warn(
|
|
811
|
+
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
function isInternalHost(hostname) {
|
|
815
|
+
if (!hostname) return false;
|
|
816
|
+
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
817
|
+
host = host.replace(/%[^\]]*$/, "");
|
|
818
|
+
host = host.replace(/\.$/, "");
|
|
819
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
820
|
+
if (host === "metadata.google.internal") return true;
|
|
821
|
+
if (host.includes(":")) {
|
|
822
|
+
const g = expandIPv6(host);
|
|
823
|
+
if (!g) return false;
|
|
824
|
+
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
825
|
+
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
826
|
+
if ((g[0] & 65472) === 65152) return true;
|
|
827
|
+
if ((g[0] & 65024) === 64512) return true;
|
|
828
|
+
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
829
|
+
return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
|
|
830
|
+
}
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
const v4 = parseIPv4ToInt(host);
|
|
834
|
+
if (v4 !== null) return isPrivateV4Int(v4);
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
function isPrivateV4Int(n) {
|
|
838
|
+
const a = n >>> 24 & 255;
|
|
839
|
+
const b = n >>> 16 & 255;
|
|
840
|
+
if (a === 127 || a === 10 || a === 0) return true;
|
|
841
|
+
if (a === 169 && b === 254) return true;
|
|
842
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
843
|
+
if (a === 192 && b === 168) return true;
|
|
844
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
function parseIPv4ToInt(host) {
|
|
848
|
+
if (!/^[0-9a-fx]+(\.[0-9a-fx]+)*$/i.test(host)) return null;
|
|
849
|
+
const parts = host.split(".");
|
|
850
|
+
if (parts.length > 4) return null;
|
|
851
|
+
const nums = [];
|
|
852
|
+
for (const p of parts) {
|
|
853
|
+
let n;
|
|
854
|
+
if (/^0x[0-9a-f]+$/i.test(p)) n = parseInt(p, 16);
|
|
855
|
+
else if (/^0[0-7]+$/.test(p)) n = parseInt(p, 8);
|
|
856
|
+
else if (/^[0-9]+$/.test(p)) n = parseInt(p, 10);
|
|
857
|
+
else return null;
|
|
858
|
+
if (!Number.isInteger(n) || n < 0) return null;
|
|
859
|
+
nums.push(n);
|
|
860
|
+
}
|
|
861
|
+
const last = nums.length - 1;
|
|
862
|
+
let value = 0;
|
|
863
|
+
for (let i = 0; i < last; i++) {
|
|
864
|
+
if (nums[i] > 255) return null;
|
|
865
|
+
value += nums[i] * 2 ** (8 * (3 - i));
|
|
866
|
+
}
|
|
867
|
+
const maxLast = 2 ** (8 * (5 - nums.length)) - 1;
|
|
868
|
+
if (nums[last] > maxLast) return null;
|
|
869
|
+
value += nums[last];
|
|
870
|
+
if (value < 0 || value > 4294967295) return null;
|
|
871
|
+
return value >>> 0;
|
|
872
|
+
}
|
|
873
|
+
function expandIPv6(host) {
|
|
874
|
+
if (!host.includes(":")) return null;
|
|
875
|
+
let s = host;
|
|
876
|
+
const lastColon = s.lastIndexOf(":");
|
|
877
|
+
const tail = s.slice(lastColon + 1);
|
|
878
|
+
if (tail.includes(".")) {
|
|
879
|
+
const v4 = parseIPv4ToInt(tail);
|
|
880
|
+
if (v4 === null) return null;
|
|
881
|
+
s = s.slice(0, lastColon + 1) + (v4 >>> 16 & 65535).toString(16) + ":" + (v4 & 65535).toString(16);
|
|
882
|
+
}
|
|
883
|
+
const halves = s.split("::");
|
|
884
|
+
if (halves.length > 2) return null;
|
|
885
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
886
|
+
const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
887
|
+
let groups;
|
|
888
|
+
if (back === null) {
|
|
889
|
+
groups = head;
|
|
890
|
+
} else {
|
|
891
|
+
const fill = 8 - head.length - back.length;
|
|
892
|
+
if (fill < 0) return null;
|
|
893
|
+
groups = [...head, ...Array(fill).fill("0"), ...back];
|
|
894
|
+
}
|
|
895
|
+
if (groups.length !== 8) return null;
|
|
896
|
+
const out = [];
|
|
897
|
+
for (const g of groups) {
|
|
898
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
899
|
+
out.push(parseInt(g, 16));
|
|
900
|
+
}
|
|
901
|
+
return out;
|
|
902
|
+
}
|
|
903
|
+
function ruleAppliesTo(when, resolver) {
|
|
904
|
+
try {
|
|
905
|
+
if (when.method) {
|
|
906
|
+
const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
|
|
907
|
+
const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
|
|
908
|
+
if (!methods.includes(actual)) return false;
|
|
909
|
+
}
|
|
910
|
+
if (when.path) {
|
|
911
|
+
const uri = String(resolver.resolve("server.REQUEST_URI")[0] ?? "/");
|
|
912
|
+
if (!pathMatches(String(when.path), uri.split("?")[0])) return false;
|
|
913
|
+
}
|
|
914
|
+
return true;
|
|
915
|
+
} catch {
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function pathMatches(pattern, path) {
|
|
920
|
+
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
921
|
+
const re = safeRegExp(pattern);
|
|
922
|
+
return re ? re.test(path) : false;
|
|
923
|
+
}
|
|
924
|
+
if (pattern.includes("*")) {
|
|
925
|
+
const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
|
|
926
|
+
return re ? re.test(path) : false;
|
|
927
|
+
}
|
|
928
|
+
return path === pattern;
|
|
929
|
+
}
|
|
930
|
+
function normalizeDefaultPort(host) {
|
|
931
|
+
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
932
|
+
}
|
|
933
|
+
function isCrossOrigin(resolver) {
|
|
934
|
+
try {
|
|
935
|
+
const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
|
|
936
|
+
if (!host) return false;
|
|
937
|
+
const originRaw = resolver.resolve("server.HTTP_ORIGIN")[0];
|
|
938
|
+
const hasOrigin = originRaw !== void 0 && originRaw !== null;
|
|
939
|
+
const src = hasOrigin ? originRaw : resolver.resolve("server.HTTP_REFERER")[0];
|
|
940
|
+
if (src === void 0 || src === null) return false;
|
|
941
|
+
const s = String(src).trim();
|
|
942
|
+
if (hasOrigin && (s === "" || s.toLowerCase() === "null")) return true;
|
|
943
|
+
const srcHost = hostFromUrl(s);
|
|
944
|
+
if (srcHost === null) return hasOrigin;
|
|
945
|
+
return normalizeDefaultPort(srcHost) !== host;
|
|
946
|
+
} catch {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function hostFromUrl(value) {
|
|
951
|
+
try {
|
|
952
|
+
return new URL(value).host.toLowerCase();
|
|
953
|
+
} catch {
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function isOffOriginRedirect(resolver) {
|
|
958
|
+
try {
|
|
959
|
+
const status = Number(resolver.resolve("response.status")[0] ?? 0);
|
|
960
|
+
if (status < 300 || status >= 400) return false;
|
|
961
|
+
const location = resolver.resolve("response.header.location")[0];
|
|
962
|
+
if (!location) return false;
|
|
963
|
+
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
|
|
964
|
+
if (!host) return false;
|
|
965
|
+
const loc = String(location).replace(/[\t\r\n]/g, "").replace(/\\/g, "/");
|
|
966
|
+
let target;
|
|
967
|
+
try {
|
|
968
|
+
target = new URL(loc, "http://" + host).host.toLowerCase();
|
|
969
|
+
} catch {
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
return normalizeDefaultPort(target) !== normalizeDefaultPort(host);
|
|
973
|
+
} catch {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function isReflectedCorsWithCredentials(resolver) {
|
|
978
|
+
try {
|
|
979
|
+
const acac = String(resolver.resolve("response.header.access-control-allow-credentials")[0] ?? "").toLowerCase();
|
|
980
|
+
if (acac !== "true") return false;
|
|
981
|
+
const acao = String(resolver.resolve("response.header.access-control-allow-origin")[0] ?? "");
|
|
982
|
+
if (!acao) return false;
|
|
983
|
+
if (acao === "*") return true;
|
|
984
|
+
if (acao.toLowerCase() === "null") return true;
|
|
985
|
+
const origin = String(resolver.resolve("server.HTTP_ORIGIN")[0] ?? "");
|
|
986
|
+
if (!origin) return false;
|
|
987
|
+
return acao === origin;
|
|
988
|
+
} catch {
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
function matchValue(type, value, matchVal, matchObj) {
|
|
993
|
+
if (value === null || value === void 0) {
|
|
994
|
+
if (type === "isset") {
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
let strValue;
|
|
1000
|
+
if (typeof value === "string") {
|
|
1001
|
+
strValue = value;
|
|
1002
|
+
} else {
|
|
1003
|
+
try {
|
|
1004
|
+
strValue = String(value);
|
|
1005
|
+
} catch {
|
|
1006
|
+
strValue = "";
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
switch (type) {
|
|
1010
|
+
case "equals":
|
|
1011
|
+
return strValue == matchVal;
|
|
1012
|
+
case "equals_strict":
|
|
1013
|
+
return strValue === matchVal;
|
|
1014
|
+
case "contains":
|
|
1015
|
+
// engine-php exposes `stripos` as an alias of `contains`.
|
|
1016
|
+
case "stripos": {
|
|
1017
|
+
const lower = strValue.toLowerCase();
|
|
1018
|
+
const target = String(matchVal).toLowerCase();
|
|
1019
|
+
return lower.includes(target);
|
|
1020
|
+
}
|
|
1021
|
+
case "not_contains": {
|
|
1022
|
+
const lower = strValue.toLowerCase();
|
|
1023
|
+
const target = String(matchVal).toLowerCase();
|
|
1024
|
+
return !lower.includes(target);
|
|
1025
|
+
}
|
|
1026
|
+
case "regex": {
|
|
1027
|
+
const regex = safeRegExp(matchVal);
|
|
1028
|
+
if (!regex) {
|
|
1029
|
+
warnRejectedPatternOnce(matchVal);
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
return regex.test(strValue);
|
|
1033
|
+
}
|
|
1034
|
+
case "more_than":
|
|
1035
|
+
return Number(strValue) > Number(matchVal);
|
|
1036
|
+
case "less_than":
|
|
1037
|
+
return Number(strValue) < Number(matchVal);
|
|
1038
|
+
case "ctype_digit":
|
|
1039
|
+
return ctypeResult(strValue, /^\d+$/.test(strValue), matchVal);
|
|
1040
|
+
case "ctype_alnum":
|
|
1041
|
+
return ctypeResult(strValue, /^[\w$\u0080-\uFFFF]+$/.test(strValue), matchVal);
|
|
1042
|
+
case "is_numeric":
|
|
1043
|
+
return ctypeResult(strValue, !isNaN(strValue) && strValue.trim() !== "", matchVal);
|
|
1044
|
+
case "isset":
|
|
1045
|
+
return true;
|
|
1046
|
+
case "in_array": {
|
|
1047
|
+
const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
|
|
1048
|
+
return arr.includes(strValue);
|
|
1049
|
+
}
|
|
1050
|
+
case "not_in_array": {
|
|
1051
|
+
const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
|
|
1052
|
+
return !arr.includes(strValue);
|
|
1053
|
+
}
|
|
1054
|
+
case "array_in_array": {
|
|
1055
|
+
if (!Array.isArray(value) || !Array.isArray(matchVal)) {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
const target = matchVal.map((x) => String(x));
|
|
1059
|
+
return value.some((v) => target.includes(String(v)));
|
|
1060
|
+
}
|
|
1061
|
+
case "hostname": {
|
|
1062
|
+
try {
|
|
1063
|
+
const url = new URL(strValue);
|
|
1064
|
+
return url.hostname === matchVal;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
case "internal_host":
|
|
1070
|
+
return isInternalHost(strValue);
|
|
1071
|
+
case "quotes":
|
|
1072
|
+
// engine-php exposes `inline_js_xss` as an alias of `quotes`.
|
|
1073
|
+
case "inline_js_xss":
|
|
1074
|
+
return /['"]/.test(strValue);
|
|
1075
|
+
case "inline_xss":
|
|
1076
|
+
return /['"]/.test(strValue) && /[>=]/.test(strValue);
|
|
1077
|
+
case "ctype_special": {
|
|
1078
|
+
const stripped = strValue.replace(/[ _\-,]/g, "");
|
|
1079
|
+
return ctypeResult(strValue, /^[\w$-]*$/.test(stripped), matchVal);
|
|
1080
|
+
}
|
|
1081
|
+
case "array_key_value":
|
|
1082
|
+
return arrayKeyValue(value, matchObj);
|
|
1083
|
+
// file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
|
|
1084
|
+
// not yet implemented in the JS engine (needs multipart body access). Kept as an
|
|
1085
|
+
// explicit, documented no-match rather than silently hitting `default`.
|
|
1086
|
+
case "file_contains":
|
|
1087
|
+
return false;
|
|
1088
|
+
// WordPress-only match types (current_user_cannot, general_xss, getShortcodeAtts,
|
|
1089
|
+
// getBlockAtts) have been removed: they depend on WP capabilities, wp_kses_post, and
|
|
1090
|
+
// the shortcode/block parsers, and have no meaning in a JS app. They now fall through
|
|
1091
|
+
// here and are reported once instead of pretending to be a no-match.
|
|
1092
|
+
default:
|
|
1093
|
+
warnUnsupportedMatchType(type);
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
var RuleEngine = class {
|
|
1098
|
+
#rules;
|
|
1099
|
+
#whitelists;
|
|
1100
|
+
#whitelistKeys;
|
|
1101
|
+
#onError;
|
|
1102
|
+
#reportedErrors = /* @__PURE__ */ new Set();
|
|
1103
|
+
constructor({ firewall = [], whitelists = [], whitelist_keys = {}, onError } = {}) {
|
|
1104
|
+
this.#rules = firewall;
|
|
1105
|
+
this.#whitelists = whitelists;
|
|
1106
|
+
this.#whitelistKeys = whitelist_keys;
|
|
1107
|
+
this.#onError = onError;
|
|
1108
|
+
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
1109
|
+
warnOnce2(
|
|
1110
|
+
"whitelist-no-rule-id",
|
|
1111
|
+
"[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
1115
|
+
warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// A mitigation engine must never take down the app it protects: any error while
|
|
1119
|
+
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
1120
|
+
// malformed rule is skipped without aborting the rest of the ruleset.
|
|
1121
|
+
#reportError(err) {
|
|
1122
|
+
if (this.#onError) {
|
|
1123
|
+
this.#onError(err);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const key = err && err.message ? err.message : String(err);
|
|
1127
|
+
if (this.#reportedErrors.has(key)) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
this.#reportedErrors.add(key);
|
|
1131
|
+
console.error("[patchstack] rule evaluation error (rule skipped, request allowed):", err);
|
|
1132
|
+
}
|
|
1133
|
+
evaluate(req) {
|
|
1134
|
+
let normalizedReq;
|
|
1135
|
+
let resolver;
|
|
1136
|
+
try {
|
|
1137
|
+
normalizedReq = { ...req, ...normalizeRequest(req) };
|
|
1138
|
+
resolver = new RequestResolver(normalizedReq);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
this.#reportError(err);
|
|
1141
|
+
return { blocked: false, rule: null, message: null };
|
|
1142
|
+
}
|
|
1143
|
+
for (const rule of this.#rules) {
|
|
1144
|
+
try {
|
|
1145
|
+
if (rule.when && !ruleAppliesTo(rule.when, resolver)) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
const conditions = rule.rule_v2;
|
|
1149
|
+
if (!Array.isArray(conditions) || conditions.length === 0) {
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (this.#evaluateRule(conditions, resolver)) {
|
|
1153
|
+
if (this.#isWhitelisted(normalizedReq, rule)) {
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
blocked: true,
|
|
1158
|
+
rule,
|
|
1159
|
+
message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
this.#reportError(err);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return { blocked: false, rule: null, message: null };
|
|
1167
|
+
}
|
|
1168
|
+
#evaluateRule(conditions, resolver) {
|
|
1169
|
+
const inclusiveConditions = conditions.filter((c) => c.inclusive);
|
|
1170
|
+
const nonInclusiveConditions = conditions.filter((c) => !c.inclusive);
|
|
1171
|
+
if (inclusiveConditions.length > 0) {
|
|
1172
|
+
let inclusiveHits = 0;
|
|
1173
|
+
for (const condition of inclusiveConditions) {
|
|
1174
|
+
if (this.#evaluateCondition(condition, resolver)) {
|
|
1175
|
+
inclusiveHits++;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (inclusiveHits === inclusiveConditions.length) {
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
for (const condition of nonInclusiveConditions) {
|
|
1183
|
+
if (this.#evaluateCondition(condition, resolver)) {
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
#evaluateCondition(condition, resolver) {
|
|
1190
|
+
const { parameter, match, mutations } = condition;
|
|
1191
|
+
if (parameter === "rules" && Array.isArray(condition.rules)) {
|
|
1192
|
+
return this.#evaluateRule(condition.rules, resolver);
|
|
1193
|
+
}
|
|
1194
|
+
if (match && match.type === "cross_origin") {
|
|
1195
|
+
return isCrossOrigin(resolver);
|
|
1196
|
+
}
|
|
1197
|
+
if (match && match.type === "off_origin") {
|
|
1198
|
+
return isOffOriginRedirect(resolver);
|
|
1199
|
+
}
|
|
1200
|
+
if (match && match.type === "cors_reflected") {
|
|
1201
|
+
return isReflectedCorsWithCredentials(resolver);
|
|
1202
|
+
}
|
|
1203
|
+
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
1204
|
+
const values = params.flatMap((p) => resolver.resolve(p));
|
|
1205
|
+
if (values.length === 0) {
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
for (let value of values) {
|
|
1209
|
+
if (mutations) {
|
|
1210
|
+
value = resolver.applyMutations(mutations, value);
|
|
1211
|
+
}
|
|
1212
|
+
if (match.type === "array_key_value") {
|
|
1213
|
+
if (matchValue(match.type, value, match.value, match)) {
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
if (typeof value === "object" && value !== null) {
|
|
1219
|
+
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
1220
|
+
if (matchValue(match.type, value, match.value, match)) return true;
|
|
1221
|
+
} else {
|
|
1222
|
+
for (const leaf of collectLeafValues(value)) {
|
|
1223
|
+
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
if (matchValue(match.type, value, match.value, match)) {
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
#isWhitelisted(req, rule) {
|
|
1235
|
+
if (!this.#whitelists || this.#whitelists.length === 0) {
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
for (const whitelist of this.#whitelists) {
|
|
1239
|
+
if (!Array.isArray(whitelist.rule_v2)) {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
if (whitelist.rule_id && whitelist.rule_id !== rule.id) {
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const resolver = new RequestResolver(req);
|
|
1246
|
+
if (this.#evaluateRule(whitelist.rule_v2, resolver)) {
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
// src/pulse-token.ts
|
|
1255
|
+
var TOKEN_SKEW_MS = 6e4;
|
|
1256
|
+
function buildTokenUrl(manifestEndpoint) {
|
|
1257
|
+
const url = new URL(manifestEndpoint);
|
|
1258
|
+
const path = url.pathname.replace(/\/$/, "");
|
|
1259
|
+
url.pathname = path.endsWith("/manifest") ? `${path.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
|
|
1260
|
+
url.search = "";
|
|
1261
|
+
url.hash = "";
|
|
1262
|
+
return url.toString();
|
|
1263
|
+
}
|
|
1264
|
+
function parsePulseAuth(credential) {
|
|
1265
|
+
const index = credential.lastIndexOf("-");
|
|
1266
|
+
if (index <= 0 || index === credential.length - 1) return null;
|
|
1267
|
+
const clientId = credential.slice(index + 1);
|
|
1268
|
+
if (!/^\d+$/.test(clientId)) return null;
|
|
1269
|
+
return { clientId, clientSecret: credential.slice(0, index) };
|
|
1270
|
+
}
|
|
1271
|
+
var cached = null;
|
|
1272
|
+
var inflight = null;
|
|
1273
|
+
async function getPulseToken(config, fetchImpl = fetch) {
|
|
1274
|
+
if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
|
|
1275
|
+
if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
|
|
1276
|
+
return cached.token;
|
|
1277
|
+
}
|
|
1278
|
+
if (inflight !== null) return inflight;
|
|
1279
|
+
const credentials = parsePulseAuth(config.pulseAuth);
|
|
1280
|
+
if (credentials === null) return null;
|
|
1281
|
+
inflight = (async () => {
|
|
1282
|
+
try {
|
|
1283
|
+
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
1284
|
+
method: "POST",
|
|
1285
|
+
headers: {
|
|
1286
|
+
"Content-Type": "application/json",
|
|
1287
|
+
Accept: "application/json",
|
|
1288
|
+
"User-Agent": "@patchstack/connect"
|
|
1289
|
+
},
|
|
1290
|
+
body: JSON.stringify({
|
|
1291
|
+
grant_type: "client_credentials",
|
|
1292
|
+
client_id: credentials.clientId,
|
|
1293
|
+
client_secret: credentials.clientSecret
|
|
1294
|
+
}),
|
|
1295
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1296
|
+
});
|
|
1297
|
+
if (!response.ok) return null;
|
|
1298
|
+
const body = await response.json();
|
|
1299
|
+
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
1300
|
+
const expiresIn = Number(body.expires_in);
|
|
1301
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
1302
|
+
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
1303
|
+
return body.access_token;
|
|
1304
|
+
} catch {
|
|
1305
|
+
return null;
|
|
1306
|
+
} finally {
|
|
1307
|
+
inflight = null;
|
|
1308
|
+
}
|
|
1309
|
+
})();
|
|
1310
|
+
return inflight;
|
|
1311
|
+
}
|
|
1312
|
+
async function pulseAuthHeader(config, fetchImpl = fetch) {
|
|
1313
|
+
const token = await getPulseToken(config, fetchImpl);
|
|
1314
|
+
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// src/protect/engine/pulse-client.js
|
|
1318
|
+
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
1319
|
+
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
1320
|
+
var JITTER_FRACTION2 = 0.1;
|
|
1321
|
+
var PulseRuleClient = class {
|
|
1322
|
+
#siteUuid;
|
|
1323
|
+
#timeoutMs;
|
|
1324
|
+
#baseUrl;
|
|
1325
|
+
#cacheTtl;
|
|
1326
|
+
#cache = null;
|
|
1327
|
+
#cacheTime = null;
|
|
1328
|
+
#ttlEffective = 0;
|
|
1329
|
+
#etag;
|
|
1330
|
+
#pulseAuth;
|
|
1331
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
|
|
1332
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1333
|
+
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1334
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1335
|
+
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1336
|
+
this.#etag = etag ?? null;
|
|
1337
|
+
this.#pulseAuth = pulseAuth ?? null;
|
|
1338
|
+
if (!this.#siteUuid) {
|
|
1339
|
+
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async getRules() {
|
|
1343
|
+
const now = Date.now();
|
|
1344
|
+
if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
|
|
1345
|
+
return this.#cache;
|
|
1346
|
+
}
|
|
1347
|
+
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1348
|
+
try {
|
|
1349
|
+
const headers = {
|
|
1350
|
+
Accept: "application/json",
|
|
1351
|
+
...await pulseAuthHeader(
|
|
1352
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
1353
|
+
fetch
|
|
1354
|
+
)
|
|
1355
|
+
};
|
|
1356
|
+
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1357
|
+
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1358
|
+
if (response.status === 304) {
|
|
1359
|
+
this.#touch(now);
|
|
1360
|
+
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1361
|
+
}
|
|
1362
|
+
if (!response.ok) {
|
|
1363
|
+
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1364
|
+
}
|
|
1365
|
+
const data = await response.json();
|
|
1366
|
+
if (!data || !Array.isArray(data.firewall)) {
|
|
1367
|
+
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1368
|
+
}
|
|
1369
|
+
const result = {
|
|
1370
|
+
success: true,
|
|
1371
|
+
notModified: false,
|
|
1372
|
+
etag: response.headers?.get?.("etag") ?? null,
|
|
1373
|
+
firewall: data.firewall,
|
|
1374
|
+
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
1375
|
+
whitelist_keys: data.whitelist_keys ?? {},
|
|
1376
|
+
...enforcementField(data)
|
|
1377
|
+
};
|
|
1378
|
+
this.#cache = result;
|
|
1379
|
+
this.#etag = result.etag;
|
|
1380
|
+
this.#touch(now);
|
|
1381
|
+
return result;
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
return {
|
|
1384
|
+
success: false,
|
|
1385
|
+
error: err.name === "TimeoutError" ? "Request timed out" : err.message,
|
|
1386
|
+
firewall: [],
|
|
1387
|
+
whitelists: [],
|
|
1388
|
+
whitelist_keys: {}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
#touch(now) {
|
|
1393
|
+
this.#cacheTime = now;
|
|
1394
|
+
this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
|
|
1395
|
+
}
|
|
1396
|
+
clearCache() {
|
|
1397
|
+
this.#cache = null;
|
|
1398
|
+
this.#cacheTime = null;
|
|
1399
|
+
this.#etag = null;
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
function enforcementField(data) {
|
|
1403
|
+
if (!data || typeof data !== "object") return {};
|
|
1404
|
+
const v = data.enforcement ?? data.mode;
|
|
1405
|
+
return v === "block" || v === "dry-run" ? { enforcement: v } : {};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/protect/engine/fetch.js
|
|
1409
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
1410
|
+
async function fromFetchRequest(request, options = {}) {
|
|
1411
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
1412
|
+
const url = new URL(request.url);
|
|
1413
|
+
const method = (request.method || "GET").toUpperCase();
|
|
1414
|
+
const headers = {};
|
|
1415
|
+
request.headers.forEach((value, key) => {
|
|
1416
|
+
headers[key.toLowerCase()] = value;
|
|
1417
|
+
});
|
|
1418
|
+
const query = {};
|
|
1419
|
+
for (const [key, value] of url.searchParams) {
|
|
1420
|
+
if (key in query) {
|
|
1421
|
+
query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
|
|
1422
|
+
} else {
|
|
1423
|
+
query[key] = value;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
let rawBody = "";
|
|
1427
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
1428
|
+
rawBody = await readCappedText(request, maxBodyBytes);
|
|
1429
|
+
}
|
|
1430
|
+
const contentType = headers["content-type"] || "";
|
|
1431
|
+
let body = {};
|
|
1432
|
+
let files;
|
|
1433
|
+
if (rawBody) {
|
|
1434
|
+
const parsed = parseBody(rawBody, contentType);
|
|
1435
|
+
body = parsed.body;
|
|
1436
|
+
files = parsed.files;
|
|
1437
|
+
}
|
|
1438
|
+
const uri = url.pathname + url.search;
|
|
1439
|
+
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
1440
|
+
return {
|
|
1441
|
+
method,
|
|
1442
|
+
url: uri,
|
|
1443
|
+
originalUrl: uri,
|
|
1444
|
+
query,
|
|
1445
|
+
body,
|
|
1446
|
+
files,
|
|
1447
|
+
headers,
|
|
1448
|
+
ip: forwarded.split(",")[0].trim(),
|
|
1449
|
+
cookies: parseCookies(headers.cookie),
|
|
1450
|
+
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
1451
|
+
// drops, so prototype-pollution rules on `raw` are robust.
|
|
1452
|
+
_rawBody: rawBody
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
async function readCappedText(request, max) {
|
|
1456
|
+
let clone;
|
|
1457
|
+
try {
|
|
1458
|
+
clone = request.clone();
|
|
1459
|
+
} catch {
|
|
1460
|
+
return "";
|
|
1461
|
+
}
|
|
1462
|
+
const body = clone.body;
|
|
1463
|
+
if (body && typeof body.getReader === "function") {
|
|
1464
|
+
const reader = body.getReader();
|
|
1465
|
+
const chunks = [];
|
|
1466
|
+
let buffered = 0;
|
|
1467
|
+
try {
|
|
1468
|
+
for (; ; ) {
|
|
1469
|
+
const { done, value } = await reader.read();
|
|
1470
|
+
if (done) break;
|
|
1471
|
+
if (!value || buffered >= max) continue;
|
|
1472
|
+
const take = Math.min(value.byteLength, max - buffered);
|
|
1473
|
+
chunks.push(take === value.byteLength ? value : value.subarray(0, take));
|
|
1474
|
+
buffered += take;
|
|
1475
|
+
}
|
|
1476
|
+
} catch {
|
|
1477
|
+
return "";
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
return new TextDecoder().decode(concatChunks(chunks, buffered));
|
|
1481
|
+
} catch {
|
|
1482
|
+
return "";
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
const text = await clone.text();
|
|
1487
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
1488
|
+
} catch {
|
|
1489
|
+
return "";
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
function concatChunks(chunks, total) {
|
|
1493
|
+
const out = new Uint8Array(total);
|
|
1494
|
+
let offset = 0;
|
|
1495
|
+
for (const c of chunks) {
|
|
1496
|
+
out.set(c, offset);
|
|
1497
|
+
offset += c.byteLength;
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
function parseBody(rawBody, contentType) {
|
|
1502
|
+
const ct = String(contentType || "").toLowerCase();
|
|
1503
|
+
const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
|
|
1504
|
+
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
1505
|
+
const isMultipart = ct.includes("multipart/form-data");
|
|
1506
|
+
const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
|
|
1507
|
+
if (isMultipart) {
|
|
1508
|
+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
1509
|
+
if (boundary) return parseMultipart(rawBody, boundary);
|
|
1510
|
+
return { body: {}, files: void 0 };
|
|
1511
|
+
}
|
|
1512
|
+
if (isForm) {
|
|
1513
|
+
const body = {};
|
|
1514
|
+
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1515
|
+
return { body, files: void 0 };
|
|
1516
|
+
}
|
|
1517
|
+
if (isJson || isAmbiguous) {
|
|
1518
|
+
try {
|
|
1519
|
+
const parsed = JSON.parse(rawBody);
|
|
1520
|
+
if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
|
|
1521
|
+
} catch {
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return { body: {}, files: void 0 };
|
|
1525
|
+
}
|
|
1526
|
+
function parseMultipart(rawBody, boundary) {
|
|
1527
|
+
const body = {};
|
|
1528
|
+
const files = {};
|
|
1529
|
+
for (const part of rawBody.split("--" + boundary)) {
|
|
1530
|
+
const sep = /\r?\n\r?\n/.exec(part);
|
|
1531
|
+
if (!sep) continue;
|
|
1532
|
+
const rawHeaders = part.slice(0, sep.index);
|
|
1533
|
+
const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
|
|
1534
|
+
if (!disposition) continue;
|
|
1535
|
+
const name = /name="([^"]*)"/i.exec(disposition)?.[1];
|
|
1536
|
+
if (name == null) continue;
|
|
1537
|
+
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
1538
|
+
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
1539
|
+
if (filename !== void 0) {
|
|
1540
|
+
const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
|
|
1541
|
+
const file = { filename, type: partType, content };
|
|
1542
|
+
files[name] = name in files ? [].concat(files[name], file) : file;
|
|
1543
|
+
} else {
|
|
1544
|
+
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return { body, files };
|
|
1548
|
+
}
|
|
1549
|
+
function parseCookies(header) {
|
|
1550
|
+
const cookies = {};
|
|
1551
|
+
if (!header) {
|
|
1552
|
+
return cookies;
|
|
1553
|
+
}
|
|
1554
|
+
for (const pair of header.split(";")) {
|
|
1555
|
+
const idx = pair.indexOf("=");
|
|
1556
|
+
if (idx === -1) {
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
1560
|
+
}
|
|
1561
|
+
return cookies;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/protect/engine/node.js
|
|
1565
|
+
function fromNodeRequest(req, rawBody = "") {
|
|
1566
|
+
const method = (req.method || "GET").toUpperCase();
|
|
1567
|
+
const headers = {};
|
|
1568
|
+
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
1569
|
+
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
1570
|
+
}
|
|
1571
|
+
const host = headers.host || "localhost";
|
|
1572
|
+
let url;
|
|
1573
|
+
try {
|
|
1574
|
+
url = new URL(req.url || "/", `http://${host}`);
|
|
1575
|
+
} catch {
|
|
1576
|
+
try {
|
|
1577
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
1578
|
+
} catch {
|
|
1579
|
+
url = new URL("http://localhost/");
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
const query = {};
|
|
1583
|
+
for (const [key, value] of url.searchParams) {
|
|
1584
|
+
if (key in query) {
|
|
1585
|
+
query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
|
|
1586
|
+
} else {
|
|
1587
|
+
query[key] = value;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const contentType = headers["content-type"] || "";
|
|
1591
|
+
let body = {};
|
|
1592
|
+
let files;
|
|
1593
|
+
if (rawBody) {
|
|
1594
|
+
const parsed = parseBody(rawBody, contentType);
|
|
1595
|
+
body = parsed.body;
|
|
1596
|
+
files = parsed.files;
|
|
1597
|
+
}
|
|
1598
|
+
const uri = url.pathname + url.search;
|
|
1599
|
+
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
1600
|
+
return {
|
|
1601
|
+
method,
|
|
1602
|
+
url: uri,
|
|
1603
|
+
originalUrl: uri,
|
|
1604
|
+
query,
|
|
1605
|
+
body,
|
|
1606
|
+
files,
|
|
1607
|
+
headers,
|
|
1608
|
+
ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
|
|
1609
|
+
cookies: parseCookies2(headers.cookie),
|
|
1610
|
+
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
1611
|
+
_rawBody: rawBody
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function parseCookies2(header) {
|
|
1615
|
+
const cookies = {};
|
|
1616
|
+
if (!header) {
|
|
1617
|
+
return cookies;
|
|
1618
|
+
}
|
|
1619
|
+
for (const pair of header.split(";")) {
|
|
1620
|
+
const idx = pair.indexOf("=");
|
|
1621
|
+
if (idx === -1) {
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
1625
|
+
}
|
|
1626
|
+
return cookies;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/protect/egress.js
|
|
1630
|
+
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
1631
|
+
const restores = [];
|
|
1632
|
+
if (typeof shouldBlock !== "function") return () => {
|
|
1633
|
+
};
|
|
1634
|
+
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1635
|
+
const block = (url, host, method) => {
|
|
1636
|
+
if (!shouldBlock(url, host, method)) return false;
|
|
1637
|
+
onBlock?.({ url, host, method });
|
|
1638
|
+
return true;
|
|
1639
|
+
};
|
|
1640
|
+
let screen = null;
|
|
1641
|
+
if (dnsScreen) {
|
|
1642
|
+
try {
|
|
1643
|
+
const resolveLookup = lookup ?? (await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))).lookup;
|
|
1644
|
+
const { isIP } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
|
|
1645
|
+
if (typeof resolveLookup === "function" && typeof isIP === "function") {
|
|
1646
|
+
screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
|
|
1647
|
+
}
|
|
1648
|
+
} catch {
|
|
1649
|
+
screen = null;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const skip = (reason, detail) => {
|
|
1653
|
+
try {
|
|
1654
|
+
onSkip?.({ phase: "egress", reason, detail });
|
|
1655
|
+
} catch {
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1659
|
+
if (!screen) {
|
|
1660
|
+
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
1661
|
+
return resolve(false);
|
|
1662
|
+
}
|
|
1663
|
+
if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
|
|
1664
|
+
try {
|
|
1665
|
+
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
1666
|
+
if (err || !Array.isArray(addresses)) {
|
|
1667
|
+
skip("resolver-failed", { host });
|
|
1668
|
+
return resolve(false);
|
|
1669
|
+
}
|
|
1670
|
+
for (const a of addresses) {
|
|
1671
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
1672
|
+
if (ip && block(url, ip, method)) return resolve(true);
|
|
1673
|
+
}
|
|
1674
|
+
resolve(false);
|
|
1675
|
+
});
|
|
1676
|
+
} catch {
|
|
1677
|
+
skip("resolver-failed", { host });
|
|
1678
|
+
resolve(false);
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
const originalFetch = globalThis.fetch;
|
|
1682
|
+
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
1683
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
1684
|
+
const MAX_REDIRECTS = 20;
|
|
1685
|
+
const screenUrl = async (u, method) => {
|
|
1686
|
+
let host = null;
|
|
1687
|
+
try {
|
|
1688
|
+
host = new URL(u).hostname;
|
|
1689
|
+
} catch {
|
|
1690
|
+
host = null;
|
|
1691
|
+
}
|
|
1692
|
+
if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
|
|
1693
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
const guarded = async (input, init) => {
|
|
1697
|
+
let cur;
|
|
1698
|
+
try {
|
|
1699
|
+
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
1700
|
+
} catch {
|
|
1701
|
+
return originalFetch(input, init);
|
|
1702
|
+
}
|
|
1703
|
+
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
1704
|
+
let url = cur.url;
|
|
1705
|
+
let method = cur.method;
|
|
1706
|
+
await screenUrl(url, method);
|
|
1707
|
+
if (callerRedirect !== "follow") return originalFetch(input, init);
|
|
1708
|
+
const headers = new Headers(cur.headers);
|
|
1709
|
+
const signal = cur.signal;
|
|
1710
|
+
let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
|
|
1711
|
+
for (let hop = 0; ; hop++) {
|
|
1712
|
+
const resp = await originalFetch(
|
|
1713
|
+
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
1714
|
+
);
|
|
1715
|
+
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
1716
|
+
if (!location) return resp;
|
|
1717
|
+
if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
|
|
1718
|
+
const next = new URL(location, url).href;
|
|
1719
|
+
if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
|
|
1720
|
+
method = "GET";
|
|
1721
|
+
body = void 0;
|
|
1722
|
+
headers.delete("content-type");
|
|
1723
|
+
headers.delete("content-length");
|
|
1724
|
+
}
|
|
1725
|
+
if (new URL(next).origin !== new URL(url).origin) {
|
|
1726
|
+
headers.delete("authorization");
|
|
1727
|
+
headers.delete("cookie");
|
|
1728
|
+
}
|
|
1729
|
+
await screenUrl(next, method);
|
|
1730
|
+
url = next;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
guarded.__patchstackGuarded = true;
|
|
1734
|
+
globalThis.fetch = guarded;
|
|
1735
|
+
restores.push(() => {
|
|
1736
|
+
if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
for (const moduleName of ["node:http", "node:https"]) {
|
|
1740
|
+
try {
|
|
1741
|
+
const mod = await import(moduleName);
|
|
1742
|
+
const restore = patchHttpModule(mod.default ?? mod, block, screen);
|
|
1743
|
+
if (restore) restores.push(restore);
|
|
1744
|
+
} catch {
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return () => {
|
|
1748
|
+
for (const restore of restores) {
|
|
1749
|
+
try {
|
|
1750
|
+
restore();
|
|
1751
|
+
} catch {
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function patchHttpModule(http, block, screen) {
|
|
1757
|
+
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
1758
|
+
const originalRequest = http.request;
|
|
1759
|
+
const originalGet = http.get;
|
|
1760
|
+
const wrap = (original) => function(...args) {
|
|
1761
|
+
const target = extractHttpTarget(args);
|
|
1762
|
+
if (target && block(target.url, target.host, target.method)) {
|
|
1763
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
|
|
1764
|
+
}
|
|
1765
|
+
if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
|
|
1766
|
+
try {
|
|
1767
|
+
args = withScreeningLookup(args, target, block, screen.lookup);
|
|
1768
|
+
} catch {
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return original.apply(this, args);
|
|
1772
|
+
};
|
|
1773
|
+
const guardedRequest = wrap(originalRequest);
|
|
1774
|
+
http.request = guardedRequest;
|
|
1775
|
+
let guardedGet;
|
|
1776
|
+
if (typeof originalGet === "function") {
|
|
1777
|
+
guardedGet = wrap(originalGet);
|
|
1778
|
+
http.get = guardedGet;
|
|
1779
|
+
}
|
|
1780
|
+
http.__patchstackGuarded = true;
|
|
1781
|
+
return () => {
|
|
1782
|
+
if (http.request === guardedRequest) http.request = originalRequest;
|
|
1783
|
+
if (guardedGet && http.get === guardedGet) http.get = originalGet;
|
|
1784
|
+
delete http.__patchstackGuarded;
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
function extractHttpTarget(args) {
|
|
1788
|
+
const first = args[0];
|
|
1789
|
+
try {
|
|
1790
|
+
if (typeof first === "string" || first instanceof URL) {
|
|
1791
|
+
const url = new URL(String(first));
|
|
1792
|
+
const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
1793
|
+
return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
|
|
1794
|
+
}
|
|
1795
|
+
if (first && typeof first === "object") {
|
|
1796
|
+
const host = normalizeHost(first.hostname || first.host) || "localhost";
|
|
1797
|
+
const protocol = first.protocol || "http:";
|
|
1798
|
+
const port = first.port ? `:${first.port}` : "";
|
|
1799
|
+
const path = first.path || "/";
|
|
1800
|
+
return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
|
|
1801
|
+
}
|
|
1802
|
+
} catch {
|
|
1803
|
+
}
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
function normalizeHost(raw) {
|
|
1807
|
+
const host = String(raw || "").trim();
|
|
1808
|
+
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
|
1809
|
+
if (bracketed) return bracketed[1];
|
|
1810
|
+
if ((host.match(/:/g) || []).length > 1) return host;
|
|
1811
|
+
return host.split(":")[0];
|
|
1812
|
+
}
|
|
1813
|
+
function screenResolved(addresses, target, block) {
|
|
1814
|
+
for (const a of addresses || []) {
|
|
1815
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
1816
|
+
if (ip && block(target.url, ip, target.method)) return ip;
|
|
1817
|
+
}
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1820
|
+
function withScreeningLookup(args, target, block, lookup) {
|
|
1821
|
+
const screeningLookup = (hostname, options, callback) => {
|
|
1822
|
+
let opts = options;
|
|
1823
|
+
let cb = callback;
|
|
1824
|
+
if (typeof opts === "function") {
|
|
1825
|
+
cb = opts;
|
|
1826
|
+
opts = {};
|
|
1827
|
+
}
|
|
1828
|
+
if (!opts || typeof opts !== "object") opts = {};
|
|
1829
|
+
try {
|
|
1830
|
+
lookup(hostname, { ...opts, all: true }, (err, addresses) => {
|
|
1831
|
+
if (err) return cb(err);
|
|
1832
|
+
const list = Array.isArray(addresses) ? addresses : [];
|
|
1833
|
+
const blocked = screenResolved(list, target, block);
|
|
1834
|
+
if (blocked) {
|
|
1835
|
+
return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
|
|
1836
|
+
}
|
|
1837
|
+
if (opts.all) return cb(null, list);
|
|
1838
|
+
const first = list[0];
|
|
1839
|
+
if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
|
|
1840
|
+
return cb(null, first.address, first.family);
|
|
1841
|
+
});
|
|
1842
|
+
} catch {
|
|
1843
|
+
try {
|
|
1844
|
+
lookup(hostname, opts, cb);
|
|
1845
|
+
} catch {
|
|
1846
|
+
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
return injectLookupOption(args, screeningLookup);
|
|
1851
|
+
}
|
|
1852
|
+
function injectLookupOption(args, lookup) {
|
|
1853
|
+
const first = args[0];
|
|
1854
|
+
if (first && typeof first === "object" && !(first instanceof URL)) {
|
|
1855
|
+
return [{ ...first, lookup }, ...args.slice(1)];
|
|
1856
|
+
}
|
|
1857
|
+
const rest = args.slice(1);
|
|
1858
|
+
const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
1859
|
+
if (optIdx !== -1) {
|
|
1860
|
+
const next = [...rest];
|
|
1861
|
+
next[optIdx] = { ...rest[optIdx], lookup };
|
|
1862
|
+
return [first, ...next];
|
|
1863
|
+
}
|
|
1864
|
+
const cbIdx = rest.findIndex((a) => typeof a === "function");
|
|
1865
|
+
if (cbIdx === -1) return [first, { lookup }, ...rest];
|
|
1866
|
+
return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// src/protect/defaults.js
|
|
1870
|
+
var DEFAULT_RESPONSE_RULES = [
|
|
1871
|
+
{
|
|
1872
|
+
id: "resp-private-key",
|
|
1873
|
+
title: "Private key in response body",
|
|
1874
|
+
phase: "response",
|
|
1875
|
+
category: "secret-exposure",
|
|
1876
|
+
action: "redact",
|
|
1877
|
+
prefilter: ["PRIVATE KEY"],
|
|
1878
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
1879
|
+
},
|
|
1880
|
+
{
|
|
1881
|
+
id: "resp-aws-access-key",
|
|
1882
|
+
title: "AWS access key id in response body",
|
|
1883
|
+
phase: "response",
|
|
1884
|
+
category: "secret-exposure",
|
|
1885
|
+
action: "redact",
|
|
1886
|
+
prefilter: ["AKIA", "ASIA"],
|
|
1887
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
id: "resp-gcp-api-key",
|
|
1891
|
+
title: "Google API key in response body",
|
|
1892
|
+
phase: "response",
|
|
1893
|
+
category: "secret-exposure",
|
|
1894
|
+
action: "redact",
|
|
1895
|
+
prefilter: ["AIza"],
|
|
1896
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
1897
|
+
},
|
|
1898
|
+
{
|
|
1899
|
+
id: "resp-vendor-api-key",
|
|
1900
|
+
title: "Vendor API key / token in response body",
|
|
1901
|
+
phase: "response",
|
|
1902
|
+
category: "secret-exposure",
|
|
1903
|
+
action: "redact",
|
|
1904
|
+
// High-signal, prefix-anchored provider tokens that never legitimately appear in a response
|
|
1905
|
+
// body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
|
|
1906
|
+
// Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
|
|
1907
|
+
// (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
|
|
1908
|
+
prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
|
|
1909
|
+
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])/" } }]
|
|
1910
|
+
},
|
|
1911
|
+
{
|
|
1912
|
+
id: "resp-jwt",
|
|
1913
|
+
title: "JWT in response body",
|
|
1914
|
+
phase: "response",
|
|
1915
|
+
category: "secret-exposure",
|
|
1916
|
+
action: "redact",
|
|
1917
|
+
prefilter: ["eyJ"],
|
|
1918
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
|
|
1919
|
+
},
|
|
1920
|
+
{
|
|
1921
|
+
id: "resp-db-connection-string",
|
|
1922
|
+
title: "Database connection string with credentials in response body",
|
|
1923
|
+
phase: "response",
|
|
1924
|
+
category: "secret-exposure",
|
|
1925
|
+
action: "redact",
|
|
1926
|
+
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
1927
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
id: "resp-stack-trace",
|
|
1931
|
+
title: "Node stack trace leaking in response body",
|
|
1932
|
+
phase: "response",
|
|
1933
|
+
category: "info-exposure",
|
|
1934
|
+
action: "redact",
|
|
1935
|
+
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
1936
|
+
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
1937
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
1938
|
+
},
|
|
1939
|
+
{
|
|
1940
|
+
id: "resp-sql-error",
|
|
1941
|
+
title: "SQL / ORM error disclosure in response body",
|
|
1942
|
+
phase: "response",
|
|
1943
|
+
category: "info-exposure",
|
|
1944
|
+
action: "redact",
|
|
1945
|
+
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
1946
|
+
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" } }]
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
id: "resp-exception-trace",
|
|
1950
|
+
title: "Backend exception / stack trace disclosure in response body",
|
|
1951
|
+
phase: "response",
|
|
1952
|
+
category: "info-exposure",
|
|
1953
|
+
action: "redact",
|
|
1954
|
+
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
1955
|
+
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
1956
|
+
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
1957
|
+
prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
|
|
1958
|
+
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+ \\[)/' } }]
|
|
1959
|
+
}
|
|
1960
|
+
];
|
|
1961
|
+
var DEFAULT_EGRESS_RULES = [
|
|
1962
|
+
{
|
|
1963
|
+
id: "egress-internal-address",
|
|
1964
|
+
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
1965
|
+
phase: "egress",
|
|
1966
|
+
category: "ssrf",
|
|
1967
|
+
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
1968
|
+
}
|
|
1969
|
+
];
|
|
1970
|
+
|
|
1971
|
+
// src/protect/block-page.js
|
|
1972
|
+
function escapeHtml(s) {
|
|
1973
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1974
|
+
}
|
|
1975
|
+
function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
1976
|
+
const safeUrl = escapeHtml(url);
|
|
1977
|
+
const safeCode = escapeHtml(code || "patchstack");
|
|
1978
|
+
return `<!DOCTYPE html>
|
|
1979
|
+
<html lang="en">
|
|
1980
|
+
<head>
|
|
1981
|
+
<meta charset="UTF-8">
|
|
1982
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
1983
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1984
|
+
<style type="text/css">
|
|
1985
|
+
.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}}
|
|
1986
|
+
</style>
|
|
1987
|
+
<title>Access Denied</title>
|
|
1988
|
+
</head>
|
|
1989
|
+
<body>
|
|
1990
|
+
<div class="main-container">
|
|
1991
|
+
<div class="container">
|
|
1992
|
+
<div class="content">
|
|
1993
|
+
<span class="access-denied">Access Denied</span>
|
|
1994
|
+
<div class="site-url">
|
|
1995
|
+
<input id="url" class="site-url-text" type="text"
|
|
1996
|
+
value="${safeUrl}" readonly>
|
|
1997
|
+
<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')">
|
|
1998
|
+
</div>
|
|
1999
|
+
<div class="site-url">
|
|
2000
|
+
<input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
|
|
2001
|
+
<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')">
|
|
2002
|
+
</div>
|
|
2003
|
+
<div class="descp">
|
|
2004
|
+
<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>
|
|
2005
|
+
</div>
|
|
2006
|
+
</div>
|
|
2007
|
+
</div>
|
|
2008
|
+
<div class="bottom-container">
|
|
2009
|
+
<div class="bottom-text-containers">
|
|
2010
|
+
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
2011
|
+
<a href="/" class="return-text">Return to homepage</a>
|
|
2012
|
+
</div>
|
|
2013
|
+
<div class="bottom-second-containers">
|
|
2014
|
+
<span class="secured-text">This website is secured by</span>
|
|
2015
|
+
<a href="https://patchstack.com" class="logoText">
|
|
2016
|
+
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
2017
|
+
</a>
|
|
2018
|
+
</div>
|
|
2019
|
+
</div>
|
|
2020
|
+
|
|
2021
|
+
<div class="mobile-bottom-container">
|
|
2022
|
+
<div class="mobile-bottom-text-containers">
|
|
2023
|
+
<img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
|
|
2024
|
+
<a href="/" class="return-text">Return to homepage</a>
|
|
2025
|
+
</div>
|
|
2026
|
+
<div class="mobile-bottom-second-containers">
|
|
2027
|
+
<span class="secured-text">This website is secured by</span>
|
|
2028
|
+
<a href="https://patchstack.com" class="logoText">
|
|
2029
|
+
<img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
|
|
2030
|
+
</a>
|
|
2031
|
+
</div>
|
|
2032
|
+
</div>
|
|
2033
|
+
</div>
|
|
2034
|
+
</body>
|
|
2035
|
+
<script>
|
|
2036
|
+
function copyText(id) {
|
|
2037
|
+
var copyText = document.getElementById(id)
|
|
2038
|
+
copyText.select();
|
|
2039
|
+
copyText.setSelectionRange(0, 99999);
|
|
2040
|
+
navigator.clipboard.writeText(copyText.value);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function hover(element) {
|
|
2044
|
+
element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function unhover(element) {
|
|
2048
|
+
element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
|
|
2049
|
+
}
|
|
2050
|
+
<\/script>
|
|
2051
|
+
</html>`;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// src/protect/rules/store.js
|
|
2055
|
+
var fsMod;
|
|
2056
|
+
async function loadFs() {
|
|
2057
|
+
if (fsMod !== void 0) return fsMod;
|
|
2058
|
+
try {
|
|
2059
|
+
const [fs, path] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
2060
|
+
fsMod = {
|
|
2061
|
+
readFileSync: fs.readFileSync,
|
|
2062
|
+
writeFileSync: fs.writeFileSync,
|
|
2063
|
+
mkdirSync: fs.mkdirSync,
|
|
2064
|
+
join: path.join
|
|
2065
|
+
};
|
|
2066
|
+
} catch {
|
|
2067
|
+
fsMod = null;
|
|
2068
|
+
}
|
|
2069
|
+
return fsMod;
|
|
2070
|
+
}
|
|
2071
|
+
function makeStore(options = {}) {
|
|
2072
|
+
let mem = null;
|
|
2073
|
+
const durable = durableTier(options);
|
|
2074
|
+
return {
|
|
2075
|
+
async read() {
|
|
2076
|
+
if (mem) return mem;
|
|
2077
|
+
const env = await durable.read();
|
|
2078
|
+
if (env) mem = env;
|
|
2079
|
+
return env;
|
|
2080
|
+
},
|
|
2081
|
+
async write(env) {
|
|
2082
|
+
if (env) mem = env;
|
|
2083
|
+
await durable.write(env);
|
|
2084
|
+
}
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
function durableTier(options) {
|
|
2088
|
+
const adapter = options.ruleCache;
|
|
2089
|
+
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
2090
|
+
return {
|
|
2091
|
+
read: async () => {
|
|
2092
|
+
try {
|
|
2093
|
+
return toEnvelope(await adapter.read());
|
|
2094
|
+
} catch {
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
write: async (env) => {
|
|
2099
|
+
try {
|
|
2100
|
+
await adapter.write(env);
|
|
2101
|
+
} catch {
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
const dir = options.cacheDir;
|
|
2107
|
+
return {
|
|
2108
|
+
read: async () => cacheRead(dir),
|
|
2109
|
+
write: async (env) => cacheWrite(dir, env)
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
async function cacheWrite(dir, env) {
|
|
2113
|
+
if (!dir) return;
|
|
2114
|
+
const fs = await loadFs();
|
|
2115
|
+
if (!fs) return;
|
|
2116
|
+
try {
|
|
2117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2118
|
+
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
2119
|
+
} catch {
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
async function cacheRead(dir) {
|
|
2123
|
+
if (!dir) return null;
|
|
2124
|
+
const fs = await loadFs();
|
|
2125
|
+
if (!fs) return null;
|
|
2126
|
+
try {
|
|
2127
|
+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
2128
|
+
} catch {
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
function toEnvelope(value) {
|
|
2133
|
+
if (!value || typeof value !== "object") return null;
|
|
2134
|
+
if (value.bundle && typeof value.bundle === "object") {
|
|
2135
|
+
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
2136
|
+
}
|
|
2137
|
+
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
2138
|
+
return { bundle: value, etag: null };
|
|
2139
|
+
}
|
|
2140
|
+
return null;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/protect/rules/validate.js
|
|
2144
|
+
var LIMITS = {
|
|
2145
|
+
maxRules: 5e3,
|
|
2146
|
+
maxWhitelists: 2e3,
|
|
2147
|
+
maxConditionsPerRule: 250,
|
|
2148
|
+
maxNestingDepth: 12,
|
|
2149
|
+
maxRegexLength: 1e3,
|
|
2150
|
+
maxValueLength: 8192
|
|
2151
|
+
};
|
|
2152
|
+
var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
|
|
2153
|
+
var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
|
|
2154
|
+
function validateBundle(bundle, opts = {}) {
|
|
2155
|
+
const rejected = [];
|
|
2156
|
+
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2157
|
+
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2158
|
+
const firewall = [];
|
|
2159
|
+
for (const rule of inFirewall) {
|
|
2160
|
+
if (firewall.length >= LIMITS.maxRules) {
|
|
2161
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
const reason = ruleProblem(rule);
|
|
2165
|
+
if (reason) rejected.push({ id: idOf(rule), reason });
|
|
2166
|
+
else firewall.push(rule);
|
|
2167
|
+
}
|
|
2168
|
+
const whitelists = [];
|
|
2169
|
+
for (const wl of inWhitelists) {
|
|
2170
|
+
if (whitelists.length >= LIMITS.maxWhitelists) {
|
|
2171
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
2175
|
+
rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
const reason = conditionsProblem(wl?.rule_v2);
|
|
2179
|
+
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
|
|
2180
|
+
else whitelists.push(wl);
|
|
2181
|
+
}
|
|
2182
|
+
return {
|
|
2183
|
+
bundle: { ...bundle, firewall, whitelists },
|
|
2184
|
+
rejected
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
function idOf(rule) {
|
|
2188
|
+
const id = rule?.id ?? rule?.rule_id;
|
|
2189
|
+
return id === void 0 || id === null ? "(unidentified)" : String(id);
|
|
2190
|
+
}
|
|
2191
|
+
function ruleProblem(rule) {
|
|
2192
|
+
if (!rule || typeof rule !== "object") return "not an object";
|
|
2193
|
+
if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2194
|
+
if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
|
|
2195
|
+
const capOverride = rule.max_bytes;
|
|
2196
|
+
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
2197
|
+
return conditionsProblem(rule.rule_v2);
|
|
2198
|
+
}
|
|
2199
|
+
function conditionsProblem(conditions, depth = 0) {
|
|
2200
|
+
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2201
|
+
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2202
|
+
if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
|
|
2203
|
+
if (conditions.length > LIMITS.maxConditionsPerRule) {
|
|
2204
|
+
return `more than ${LIMITS.maxConditionsPerRule} conditions`;
|
|
2205
|
+
}
|
|
2206
|
+
for (const c of conditions) {
|
|
2207
|
+
if (!c || typeof c !== "object") return "condition is not an object";
|
|
2208
|
+
if (Array.isArray(c.rules)) {
|
|
2209
|
+
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2210
|
+
if (nested) return nested;
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
const m = c.match;
|
|
2214
|
+
if (!m || typeof m !== "object") return "condition has no match object";
|
|
2215
|
+
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
2216
|
+
if (m.type === "regex") {
|
|
2217
|
+
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2218
|
+
if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
|
|
2219
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
|
|
2220
|
+
return `match.value longer than ${LIMITS.maxValueLength} chars`;
|
|
2221
|
+
}
|
|
2222
|
+
if (m.match) {
|
|
2223
|
+
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2224
|
+
if (nested) return nested;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// src/protect/rules/source.js
|
|
2231
|
+
function liveUpdateRejections(res, options) {
|
|
2232
|
+
if (options.acceptPartialBundle) return [];
|
|
2233
|
+
const { rejected } = validateBundle(
|
|
2234
|
+
{ firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
|
|
2235
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
2236
|
+
);
|
|
2237
|
+
return rejected;
|
|
2238
|
+
}
|
|
2239
|
+
function reportRejections(rejected, options, label) {
|
|
2240
|
+
const report = options.onRuleRejected;
|
|
2241
|
+
for (const r of rejected) {
|
|
2242
|
+
if (typeof report === "function") {
|
|
2243
|
+
try {
|
|
2244
|
+
report({ ...r, accepted: false });
|
|
2245
|
+
} catch {
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2250
|
+
options.onError?.(new Error(
|
|
2251
|
+
`${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" : ""}`
|
|
2252
|
+
));
|
|
2253
|
+
}
|
|
2254
|
+
async function resolveRules(options, store, ctx = {}) {
|
|
2255
|
+
const timeoutMs = ctx.timeoutMs;
|
|
2256
|
+
if (options.siteUuid) {
|
|
2257
|
+
const prior = await store.read();
|
|
2258
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
2259
|
+
const res = await client.getRules();
|
|
2260
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2261
|
+
if (res.success && !res.notModified) {
|
|
2262
|
+
const rejected = liveUpdateRejections(res, options);
|
|
2263
|
+
if (rejected.length > 0) {
|
|
2264
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
2265
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2266
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2267
|
+
return emptyBundle();
|
|
2268
|
+
}
|
|
2269
|
+
const bundle = normalizeBundle(res, options);
|
|
2270
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
2271
|
+
return bundle;
|
|
2272
|
+
}
|
|
2273
|
+
if (prior?.bundle) {
|
|
2274
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2275
|
+
return normalizeBundle(prior.bundle, options);
|
|
2276
|
+
}
|
|
2277
|
+
if (options.rules) {
|
|
2278
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
2279
|
+
return normalizeBundle(options.rules, options);
|
|
2280
|
+
}
|
|
2281
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2282
|
+
return emptyBundle();
|
|
2283
|
+
}
|
|
2284
|
+
if (options.token) {
|
|
2285
|
+
const prior = await store.read();
|
|
2286
|
+
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2287
|
+
const res = await client.getRules();
|
|
2288
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2289
|
+
if (res.success && !res.notModified) {
|
|
2290
|
+
const rejected = liveUpdateRejections(res, options);
|
|
2291
|
+
if (rejected.length > 0) {
|
|
2292
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
2293
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2294
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2295
|
+
return emptyBundle();
|
|
2296
|
+
}
|
|
2297
|
+
const bundle = normalizeBundle(res, options);
|
|
2298
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
2299
|
+
return bundle;
|
|
2300
|
+
}
|
|
2301
|
+
if (prior?.bundle) {
|
|
2302
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2303
|
+
return normalizeBundle(prior.bundle, options);
|
|
2304
|
+
}
|
|
2305
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2306
|
+
return emptyBundle();
|
|
2307
|
+
}
|
|
2308
|
+
if (options.rules) {
|
|
2309
|
+
return normalizeBundle(options.rules, options);
|
|
2310
|
+
}
|
|
2311
|
+
return emptyBundle();
|
|
2312
|
+
}
|
|
2313
|
+
function normalizeBundle(b, options = {}) {
|
|
2314
|
+
const enforcement = b?.enforcement ?? b?.mode;
|
|
2315
|
+
const { bundle: checked, rejected } = validateBundle(
|
|
2316
|
+
{
|
|
2317
|
+
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
2318
|
+
whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
|
|
2319
|
+
},
|
|
2320
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
2321
|
+
);
|
|
2322
|
+
if (rejected.length > 0) {
|
|
2323
|
+
const report = options.onRuleRejected;
|
|
2324
|
+
if (typeof report === "function") {
|
|
2325
|
+
for (const r of rejected) {
|
|
2326
|
+
try {
|
|
2327
|
+
report(r);
|
|
2328
|
+
} catch {
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
} else {
|
|
2332
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2333
|
+
console.warn(
|
|
2334
|
+
`[patchstack] ${rejected.length} delivered rule(s) rejected as invalid/oversized and are NOT enforced: ${sample}` + (rejected.length > 3 ? ", \u2026" : "")
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
return {
|
|
2339
|
+
firewall: checked.firewall,
|
|
2340
|
+
whitelists: checked.whitelists,
|
|
2341
|
+
whitelist_keys: b.whitelist_keys ?? {},
|
|
2342
|
+
...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
function emptyBundle() {
|
|
2346
|
+
return { firewall: [], whitelists: [], whitelist_keys: {} };
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// src/protect/rules/refresh.js
|
|
2350
|
+
var JITTER_FRACTION3 = 0.1;
|
|
2351
|
+
var MAX_BACKOFF_MULTIPLIER = 8;
|
|
2352
|
+
function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
2353
|
+
let stopped = false;
|
|
2354
|
+
let failures = 0;
|
|
2355
|
+
let timer = null;
|
|
2356
|
+
const schedule = () => {
|
|
2357
|
+
if (stopped) return;
|
|
2358
|
+
const backoff = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
|
|
2359
|
+
const jitter = 1 - Math.random() * JITTER_FRACTION3;
|
|
2360
|
+
const delay = refreshMs * backoff * jitter;
|
|
2361
|
+
timer = setTimeout(run, delay);
|
|
2362
|
+
timer?.unref?.();
|
|
2363
|
+
};
|
|
2364
|
+
const run = async () => {
|
|
2365
|
+
if (stopped) return;
|
|
2366
|
+
try {
|
|
2367
|
+
await tick();
|
|
2368
|
+
failures = 0;
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
failures++;
|
|
2371
|
+
onError?.(err);
|
|
2372
|
+
}
|
|
2373
|
+
schedule();
|
|
2374
|
+
};
|
|
2375
|
+
schedule();
|
|
2376
|
+
return {
|
|
2377
|
+
stop() {
|
|
2378
|
+
stopped = true;
|
|
2379
|
+
if (timer) clearTimeout(timer);
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
function makeRefreshHandler(tick, secret) {
|
|
2384
|
+
return async (request) => {
|
|
2385
|
+
if (!secret) return new Response("not found", { status: 404 });
|
|
2386
|
+
let provided = null;
|
|
2387
|
+
try {
|
|
2388
|
+
provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
|
|
2389
|
+
} catch {
|
|
2390
|
+
provided = null;
|
|
2391
|
+
}
|
|
2392
|
+
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
2393
|
+
let refreshed = true;
|
|
2394
|
+
try {
|
|
2395
|
+
await tick();
|
|
2396
|
+
} catch {
|
|
2397
|
+
refreshed = false;
|
|
2398
|
+
}
|
|
2399
|
+
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// src/protect/firewall-log.js
|
|
2404
|
+
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
2405
|
+
var DEFAULT_FLUSH_MS = 1e3;
|
|
2406
|
+
var MAX_BATCH = 50;
|
|
2407
|
+
var TOKEN_SKEW_MS2 = 6e4;
|
|
2408
|
+
function parseApiKey(apiKey) {
|
|
2409
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
2410
|
+
const idx = apiKey.lastIndexOf("-");
|
|
2411
|
+
if (idx <= 0 || idx === apiKey.length - 1) return null;
|
|
2412
|
+
const clientSecret = apiKey.slice(0, idx);
|
|
2413
|
+
const clientId = apiKey.slice(idx + 1);
|
|
2414
|
+
if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
|
|
2415
|
+
return { clientId, clientSecret };
|
|
2416
|
+
}
|
|
2417
|
+
function resolveApiBase(pulseOrManifestUrl) {
|
|
2418
|
+
const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
|
|
2419
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
2420
|
+
const candidate = fromEnv.replace(/\/$/, "");
|
|
2421
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
2422
|
+
console.warn(
|
|
2423
|
+
"[patchstack] ignoring PATCHSTACK_API_BASE: block-log reporting requires an https origin (or localhost). Falling back to the default API origin."
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
|
|
2427
|
+
try {
|
|
2428
|
+
return new URL(pulseOrManifestUrl).origin;
|
|
2429
|
+
} catch {
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
|
|
2433
|
+
if (typeof endpoint === "string" && endpoint.length > 0) {
|
|
2434
|
+
try {
|
|
2435
|
+
return new URL(endpoint).origin;
|
|
2436
|
+
} catch {
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
return DEFAULT_API_BASE;
|
|
2440
|
+
}
|
|
2441
|
+
function createFirewallLogReporter(opts) {
|
|
2442
|
+
const creds = parseApiKey(opts.apiKey);
|
|
2443
|
+
if (!creds) {
|
|
2444
|
+
return { record() {
|
|
2445
|
+
}, flush() {
|
|
2446
|
+
}, stop() {
|
|
2447
|
+
} };
|
|
2448
|
+
}
|
|
2449
|
+
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
2450
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2451
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2452
|
+
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
2453
|
+
let queue = [];
|
|
2454
|
+
let timer = null;
|
|
2455
|
+
let stopped = false;
|
|
2456
|
+
let cachedToken = null;
|
|
2457
|
+
let tokenInflight = null;
|
|
2458
|
+
const fetchAccessToken = async () => {
|
|
2459
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
|
|
2460
|
+
return cachedToken.token;
|
|
2461
|
+
}
|
|
2462
|
+
if (tokenInflight) return tokenInflight;
|
|
2463
|
+
tokenInflight = (async () => {
|
|
2464
|
+
try {
|
|
2465
|
+
const res = await fetchImpl(`${apiBase}/oauth/token`, {
|
|
2466
|
+
method: "POST",
|
|
2467
|
+
headers: {
|
|
2468
|
+
"Content-Type": "application/json",
|
|
2469
|
+
Accept: "application/json",
|
|
2470
|
+
"User-Agent": "@patchstack/connect"
|
|
2471
|
+
},
|
|
2472
|
+
body: JSON.stringify({
|
|
2473
|
+
grant_type: "client_credentials",
|
|
2474
|
+
client_id: creds.clientId,
|
|
2475
|
+
client_secret: creds.clientSecret
|
|
2476
|
+
})
|
|
2477
|
+
});
|
|
2478
|
+
if (!res || !res.ok) return null;
|
|
2479
|
+
const body = await res.json();
|
|
2480
|
+
const token = body?.access_token;
|
|
2481
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
2482
|
+
const expiresIn = Number(body?.expires_in);
|
|
2483
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
2484
|
+
cachedToken = { token, expiresAt: Date.now() + ttlMs };
|
|
2485
|
+
return token;
|
|
2486
|
+
} catch {
|
|
2487
|
+
return null;
|
|
2488
|
+
} finally {
|
|
2489
|
+
tokenInflight = null;
|
|
2490
|
+
}
|
|
2491
|
+
})();
|
|
2492
|
+
return tokenInflight;
|
|
2493
|
+
};
|
|
2494
|
+
const flush = () => {
|
|
2495
|
+
if (timer) {
|
|
2496
|
+
clearTimeout(timer);
|
|
2497
|
+
timer = null;
|
|
2498
|
+
}
|
|
2499
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2500
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
2501
|
+
void (async () => {
|
|
2502
|
+
const token = await fetchAccessToken();
|
|
2503
|
+
if (!token) return;
|
|
2504
|
+
const body = new URLSearchParams();
|
|
2505
|
+
body.set("type", "firewall");
|
|
2506
|
+
body.set("logs", JSON.stringify(batch));
|
|
2507
|
+
try {
|
|
2508
|
+
const p = fetchImpl(`${apiBase}/api/logs/log`, {
|
|
2509
|
+
method: "POST",
|
|
2510
|
+
headers: {
|
|
2511
|
+
Authorization: `Bearer ${token}`,
|
|
2512
|
+
Accept: "application/json",
|
|
2513
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2514
|
+
"User-Agent": "@patchstack/connect",
|
|
2515
|
+
...sourceHost ? { "Source-Host": sourceHost } : {}
|
|
2516
|
+
},
|
|
2517
|
+
body
|
|
2518
|
+
});
|
|
2519
|
+
if (p && typeof p.then === "function") p.catch(() => {
|
|
2520
|
+
});
|
|
2521
|
+
} catch {
|
|
2522
|
+
}
|
|
2523
|
+
})();
|
|
2524
|
+
};
|
|
2525
|
+
return {
|
|
2526
|
+
/**
|
|
2527
|
+
* @param {{
|
|
2528
|
+
* rule?: { id?: string | number },
|
|
2529
|
+
* method?: string | null,
|
|
2530
|
+
* path?: string | null,
|
|
2531
|
+
* ip?: string | null,
|
|
2532
|
+
* userAgent?: string | null,
|
|
2533
|
+
* }} event
|
|
2534
|
+
*/
|
|
2535
|
+
record(event) {
|
|
2536
|
+
if (stopped) return;
|
|
2537
|
+
const fid = event?.rule?.id;
|
|
2538
|
+
if (fid === void 0 || fid === null || fid === "") return;
|
|
2539
|
+
queue.push({
|
|
2540
|
+
fid,
|
|
2541
|
+
method: event.method ?? null,
|
|
2542
|
+
request_uri: event.path ?? null,
|
|
2543
|
+
ip: event.ip ?? null,
|
|
2544
|
+
user_agent: event.userAgent ?? null,
|
|
2545
|
+
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2546
|
+
});
|
|
2547
|
+
if (queue.length >= MAX_BATCH) {
|
|
2548
|
+
flush();
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
2552
|
+
},
|
|
2553
|
+
flush,
|
|
2554
|
+
stop() {
|
|
2555
|
+
stopped = true;
|
|
2556
|
+
flush();
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
function telemetryEnabled() {
|
|
2561
|
+
const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
|
|
2562
|
+
if (v === void 0 || v === "") return true;
|
|
2563
|
+
return !/^(0|false|off|no)$/i.test(String(v));
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// src/protect/supabase-guard.js
|
|
2567
|
+
var GUARD_PATH = "/_patchstack/guard";
|
|
2568
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
2569
|
+
function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
|
|
2570
|
+
const guard = protection.fetchGuard();
|
|
2571
|
+
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
2572
|
+
return async function handleGuardRequest(request) {
|
|
2573
|
+
const target = request.headers.get("x-ps-target");
|
|
2574
|
+
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
2575
|
+
let targetUrl;
|
|
2576
|
+
try {
|
|
2577
|
+
targetUrl = new URL(target);
|
|
2578
|
+
} catch {
|
|
2579
|
+
return new Response("patchstack: invalid target", { status: 400 });
|
|
2580
|
+
}
|
|
2581
|
+
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
2582
|
+
return new Response("patchstack: target not allowed", { status: 403 });
|
|
2583
|
+
}
|
|
2584
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
2585
|
+
const bodyText = hasBody ? await request.text() : "";
|
|
2586
|
+
const evalReq = new Request(targetUrl.toString(), {
|
|
2587
|
+
method: request.method,
|
|
2588
|
+
headers: request.headers,
|
|
2589
|
+
body: hasBody ? bodyText : void 0
|
|
2590
|
+
});
|
|
2591
|
+
const blocked = await guard(evalReq);
|
|
2592
|
+
if (blocked) return blocked;
|
|
2593
|
+
const forwardHeaders = new Headers(request.headers);
|
|
2594
|
+
forwardHeaders.delete("x-ps-target");
|
|
2595
|
+
forwardHeaders.delete("host");
|
|
2596
|
+
const upstream = await fetchImpl(targetUrl.toString(), {
|
|
2597
|
+
method: request.method,
|
|
2598
|
+
headers: forwardHeaders,
|
|
2599
|
+
body: hasBody ? bodyText : void 0,
|
|
2600
|
+
redirect: "manual"
|
|
2601
|
+
});
|
|
2602
|
+
const outHeaders = new Headers();
|
|
2603
|
+
upstream.headers.forEach((value, key) => {
|
|
2604
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
|
|
2605
|
+
});
|
|
2606
|
+
const buf = await upstream.arrayBuffer();
|
|
2607
|
+
const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
|
|
2608
|
+
return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// src/protect/runtime.js
|
|
2613
|
+
function createServerFnGuard({ protection }) {
|
|
2614
|
+
const guard = protection.fetchGuard();
|
|
2615
|
+
return async (data) => {
|
|
2616
|
+
let res;
|
|
2617
|
+
try {
|
|
2618
|
+
const req = new Request("https://patchstack.local/_serverfn", {
|
|
2619
|
+
method: "POST",
|
|
2620
|
+
headers: { "content-type": "application/json" },
|
|
2621
|
+
body: JSON.stringify(data ?? {})
|
|
2622
|
+
});
|
|
2623
|
+
res = await guard(req);
|
|
2624
|
+
} catch {
|
|
2625
|
+
return null;
|
|
2626
|
+
}
|
|
2627
|
+
if (!res) return null;
|
|
2628
|
+
let body = {};
|
|
2629
|
+
try {
|
|
2630
|
+
body = await res.clone().json();
|
|
2631
|
+
} catch {
|
|
2632
|
+
}
|
|
2633
|
+
return { rule: body.rule, message: body.message || "Blocked by Patchstack" };
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
async function createProtection(options = {}) {
|
|
2637
|
+
const onError = options.onError;
|
|
2638
|
+
const userOnDetect = options.onDetect ?? defaultOnDetect;
|
|
2639
|
+
const apiKey = await resolveApiKey(options);
|
|
2640
|
+
const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
|
|
2641
|
+
apiKey,
|
|
2642
|
+
apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
|
|
2643
|
+
sourceHost: options.sourceHost,
|
|
2644
|
+
fetchImpl: options.fetchImpl
|
|
2645
|
+
}) : null;
|
|
2646
|
+
const onDetect = (detection) => {
|
|
2647
|
+
userOnDetect(detection);
|
|
2648
|
+
if (firewallLog && detection?.mode === "block") {
|
|
2649
|
+
firewallLog.record({
|
|
2650
|
+
rule: detection.rule,
|
|
2651
|
+
method: detection.method,
|
|
2652
|
+
path: detection.path,
|
|
2653
|
+
ip: detection.ip,
|
|
2654
|
+
userAgent: detection.userAgent
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
};
|
|
2658
|
+
const store = makeStore(options);
|
|
2659
|
+
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2660
|
+
const pulseAuth = await resolvePulseAuth(options);
|
|
2661
|
+
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
2662
|
+
let mode = resolveMode(options, bundle);
|
|
2663
|
+
let requestRules;
|
|
2664
|
+
let responseRules;
|
|
2665
|
+
let egressRules;
|
|
2666
|
+
let screenCap;
|
|
2667
|
+
let engine;
|
|
2668
|
+
let responseRuleSet;
|
|
2669
|
+
let egressEngine;
|
|
2670
|
+
const applyBundle = (delivered) => {
|
|
2671
|
+
const incoming = delivered.firewall ?? [];
|
|
2672
|
+
requestRules = byPhase(incoming, "request");
|
|
2673
|
+
responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
|
|
2674
|
+
screenCap = responseScreenCap(responseRules);
|
|
2675
|
+
egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
|
|
2676
|
+
engine = new RuleEngine({
|
|
2677
|
+
firewall: requestRules,
|
|
2678
|
+
whitelists: delivered.whitelists,
|
|
2679
|
+
whitelist_keys: delivered.whitelist_keys,
|
|
2680
|
+
onError
|
|
2681
|
+
});
|
|
2682
|
+
responseRuleSet = responseRules.map((rule) => ({
|
|
2683
|
+
rule,
|
|
2684
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
2685
|
+
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
|
|
2686
|
+
// A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
|
|
2687
|
+
// json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
|
|
2688
|
+
// they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
|
|
2689
|
+
// fails such a rule CLOSED (block) instead of serving a no-op redaction.
|
|
2690
|
+
mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
|
|
2691
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
2692
|
+
// have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
|
|
2693
|
+
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
2694
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
2695
|
+
}));
|
|
2696
|
+
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
2697
|
+
};
|
|
2698
|
+
applyBundle(bundle);
|
|
2699
|
+
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
2700
|
+
const onSkip = typeof options.onSkip === "function" ? options.onSkip : null;
|
|
2701
|
+
const recordSkip = (phase, reason, detail) => {
|
|
2702
|
+
const key = `${phase}:${reason}`;
|
|
2703
|
+
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
2704
|
+
if (onSkip) {
|
|
2705
|
+
try {
|
|
2706
|
+
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
2707
|
+
} catch {
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
};
|
|
2711
|
+
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
2712
|
+
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
2713
|
+
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
2714
|
+
if (!result || !result.blocked) return allow2();
|
|
2715
|
+
const effectiveMode = ruleMode(result.rule);
|
|
2716
|
+
onDetect({
|
|
2717
|
+
phase,
|
|
2718
|
+
// The mode this detection was actually handled under, not the site's: a consumer counting blocks
|
|
2719
|
+
// would otherwise over-report, and the whole point of a dry-run rule is that it did not block.
|
|
2720
|
+
mode: effectiveMode,
|
|
2721
|
+
category: result.rule?.category,
|
|
2722
|
+
rule: result.rule,
|
|
2723
|
+
message: result.message,
|
|
2724
|
+
method: ctx.method,
|
|
2725
|
+
path: ctx.path,
|
|
2726
|
+
ip: ctx.ip,
|
|
2727
|
+
userAgent: ctx.userAgent
|
|
2728
|
+
});
|
|
2729
|
+
return effectiveMode === "block" ? block() : allow2();
|
|
2730
|
+
};
|
|
2731
|
+
const screenText = (text, meta, reqCtx) => {
|
|
2732
|
+
let blockRule = null;
|
|
2733
|
+
const redactions = [];
|
|
2734
|
+
const headerMutations = [];
|
|
2735
|
+
let lowerText = null;
|
|
2736
|
+
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
2737
|
+
if (prefilter) {
|
|
2738
|
+
if (lowerText === null) lowerText = text.toLowerCase();
|
|
2739
|
+
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
2740
|
+
}
|
|
2741
|
+
let result;
|
|
2742
|
+
try {
|
|
2743
|
+
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
2744
|
+
} catch (err) {
|
|
2745
|
+
onError?.(err);
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
if (!result.blocked) continue;
|
|
2749
|
+
const responseMode = ruleMode(rule);
|
|
2750
|
+
onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
|
|
2751
|
+
if (responseMode !== "block") continue;
|
|
2752
|
+
if (redactors && redactors.length) {
|
|
2753
|
+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
2754
|
+
if (mutatedSpan && spanRedactors.length) {
|
|
2755
|
+
if (!blockRule) blockRule = rule;
|
|
2756
|
+
} else {
|
|
2757
|
+
redactions.push({ rule, redactors });
|
|
2758
|
+
}
|
|
2759
|
+
} else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
|
|
2760
|
+
else if (!blockRule) blockRule = rule;
|
|
2761
|
+
}
|
|
2762
|
+
if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
|
|
2763
|
+
if (blockRule) return { verdict: "block" };
|
|
2764
|
+
let body = text;
|
|
2765
|
+
const headers = { ...meta.headers || {} };
|
|
2766
|
+
for (const { rule, redactors } of redactions) {
|
|
2767
|
+
const mask = maskFn(rule.category);
|
|
2768
|
+
const transform = rule.action === "encode" ? htmlEscape : null;
|
|
2769
|
+
const pathRedactors = redactors.filter((r) => r.jsonPath);
|
|
2770
|
+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
2771
|
+
if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
|
|
2772
|
+
if (!spanRedactors.length) continue;
|
|
2773
|
+
body = applyRedactors(body, spanRedactors, mask, transform);
|
|
2774
|
+
if (transform) continue;
|
|
2775
|
+
for (const name of Object.keys(headers)) {
|
|
2776
|
+
const value = headers[name];
|
|
2777
|
+
if (typeof value === "string") {
|
|
2778
|
+
headers[name] = applyRedactors(value, spanRedactors, mask);
|
|
2779
|
+
} else if (Array.isArray(value)) {
|
|
2780
|
+
headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
2785
|
+
return { verdict: "redact", body, headers };
|
|
2786
|
+
};
|
|
2787
|
+
const reqContextFromFetch = (request) => {
|
|
2788
|
+
try {
|
|
2789
|
+
const u = new URL(request.url);
|
|
2790
|
+
const headers = headerObject(request.headers);
|
|
2791
|
+
if (!headers.host) headers.host = u.host;
|
|
2792
|
+
return { method: request.method, originalUrl: u.pathname + u.search, headers };
|
|
2793
|
+
} catch {
|
|
2794
|
+
return void 0;
|
|
2795
|
+
}
|
|
2796
|
+
};
|
|
2797
|
+
const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
|
|
2798
|
+
const screenResp = async (response, reqCtx) => {
|
|
2799
|
+
const read = await readTextResponse(response, screenCap);
|
|
2800
|
+
if (read.skip) {
|
|
2801
|
+
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
2802
|
+
return response;
|
|
2803
|
+
}
|
|
2804
|
+
const text = read.text;
|
|
2805
|
+
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
2806
|
+
if (r.verdict === "block") return leakResponse();
|
|
2807
|
+
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
2808
|
+
return response;
|
|
2809
|
+
};
|
|
2810
|
+
const wrapNodeResponse = (res, reqCtx) => {
|
|
2811
|
+
const origWrite = res.write.bind(res);
|
|
2812
|
+
const origEnd = res.end.bind(res);
|
|
2813
|
+
const chunks = [];
|
|
2814
|
+
let size = 0;
|
|
2815
|
+
let overflow = false;
|
|
2816
|
+
const MAX = screenCap;
|
|
2817
|
+
const collect = (chunk, enc) => {
|
|
2818
|
+
if (chunk == null) return;
|
|
2819
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
2820
|
+
size += buf.length;
|
|
2821
|
+
if (size > MAX) {
|
|
2822
|
+
for (const c of chunks) origWrite(c);
|
|
2823
|
+
chunks.length = 0;
|
|
2824
|
+
origWrite(buf);
|
|
2825
|
+
overflow = true;
|
|
2826
|
+
recordSkip("response", "body-cap", { bytes: size });
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
chunks.push(buf);
|
|
2830
|
+
};
|
|
2831
|
+
res.write = function(chunk, enc, cb) {
|
|
2832
|
+
if (overflow) return origWrite(chunk, enc, cb);
|
|
2833
|
+
collect(chunk, enc);
|
|
2834
|
+
if (typeof enc === "function") enc();
|
|
2835
|
+
else if (typeof cb === "function") cb();
|
|
2836
|
+
return true;
|
|
2837
|
+
};
|
|
2838
|
+
res.end = function(chunk, enc, cb) {
|
|
2839
|
+
if (typeof chunk === "function") {
|
|
2840
|
+
cb = chunk;
|
|
2841
|
+
chunk = void 0;
|
|
2842
|
+
enc = void 0;
|
|
2843
|
+
} else if (typeof enc === "function") {
|
|
2844
|
+
cb = enc;
|
|
2845
|
+
enc = void 0;
|
|
2846
|
+
}
|
|
2847
|
+
if (overflow) {
|
|
2848
|
+
if (chunk != null) origWrite(chunk, enc);
|
|
2849
|
+
return origEnd(cb);
|
|
2850
|
+
}
|
|
2851
|
+
collect(chunk, enc);
|
|
2852
|
+
if (overflow) return origEnd(cb);
|
|
2853
|
+
const buffer = Buffer.concat(chunks);
|
|
2854
|
+
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
2855
|
+
if (Array.isArray(ct)) ct = ct[0];
|
|
2856
|
+
const kind = screenableContentType(ct);
|
|
2857
|
+
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
2858
|
+
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
2859
|
+
for (const c of chunks) origWrite(c);
|
|
2860
|
+
return origEnd(cb);
|
|
2861
|
+
}
|
|
2862
|
+
const text = buffer.toString("utf8");
|
|
2863
|
+
let r;
|
|
2864
|
+
try {
|
|
2865
|
+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2866
|
+
} catch (err) {
|
|
2867
|
+
onError?.(err);
|
|
2868
|
+
for (const c of chunks) origWrite(c);
|
|
2869
|
+
return origEnd(cb);
|
|
2870
|
+
}
|
|
2871
|
+
if (r.verdict === "block") {
|
|
2872
|
+
res.statusCode = 500;
|
|
2873
|
+
try {
|
|
2874
|
+
res.setHeader("content-type", "application/json");
|
|
2875
|
+
} catch {
|
|
2876
|
+
}
|
|
2877
|
+
return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
|
|
2878
|
+
}
|
|
2879
|
+
if (r.verdict === "redact") {
|
|
2880
|
+
try {
|
|
2881
|
+
res.removeHeader && res.removeHeader("content-length");
|
|
2882
|
+
} catch {
|
|
2883
|
+
}
|
|
2884
|
+
if (r.headers && res.setHeader) {
|
|
2885
|
+
const current = res.getHeaders ? res.getHeaders() : {};
|
|
2886
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
2887
|
+
if (name.toLowerCase() === "content-length") continue;
|
|
2888
|
+
if (value === null || value === void 0) {
|
|
2889
|
+
try {
|
|
2890
|
+
res.removeHeader && res.removeHeader(name);
|
|
2891
|
+
} catch {
|
|
2892
|
+
}
|
|
2893
|
+
} else if (Array.isArray(value)) {
|
|
2894
|
+
try {
|
|
2895
|
+
res.setHeader(name, value);
|
|
2896
|
+
} catch {
|
|
2897
|
+
}
|
|
2898
|
+
} else if (typeof value === "string" && current[name] !== value) {
|
|
2899
|
+
try {
|
|
2900
|
+
res.setHeader(name, value);
|
|
2901
|
+
} catch {
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
return origEnd(r.body, cb);
|
|
2907
|
+
}
|
|
2908
|
+
for (const c of chunks) origWrite(c);
|
|
2909
|
+
return origEnd(cb);
|
|
2910
|
+
};
|
|
2911
|
+
};
|
|
2912
|
+
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
2913
|
+
const egressShouldBlock = (url, host, method) => {
|
|
2914
|
+
if (host && allow.has(host.toLowerCase())) return false;
|
|
2915
|
+
let result;
|
|
2916
|
+
try {
|
|
2917
|
+
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
2918
|
+
} catch (err) {
|
|
2919
|
+
onError?.(err);
|
|
2920
|
+
return false;
|
|
2921
|
+
}
|
|
2922
|
+
if (!result.blocked) return false;
|
|
2923
|
+
const egressMode = ruleMode(result.rule);
|
|
2924
|
+
onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
|
|
2925
|
+
return egressMode === "block";
|
|
2926
|
+
};
|
|
2927
|
+
const protection = {
|
|
2928
|
+
get mode() {
|
|
2929
|
+
return mode;
|
|
2930
|
+
},
|
|
2931
|
+
get rules() {
|
|
2932
|
+
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
2933
|
+
},
|
|
2934
|
+
/**
|
|
2935
|
+
* Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
|
|
2936
|
+
* `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
|
|
2937
|
+
* `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
|
|
2938
|
+
* `onSkip`) so an unscreened path is visible and alertable rather than silent.
|
|
2939
|
+
*/
|
|
2940
|
+
coverage() {
|
|
2941
|
+
return { skipped: { ...skipCounts } };
|
|
2942
|
+
},
|
|
2943
|
+
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
2944
|
+
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
2945
|
+
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
|
|
2946
|
+
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
2947
|
+
fetchGuard() {
|
|
2948
|
+
return async (request) => {
|
|
2949
|
+
let result;
|
|
2950
|
+
try {
|
|
2951
|
+
result = engine.evaluate(await fromFetchRequest(request));
|
|
2952
|
+
} catch (err) {
|
|
2953
|
+
onError?.(err);
|
|
2954
|
+
return null;
|
|
2955
|
+
}
|
|
2956
|
+
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
2957
|
+
};
|
|
2958
|
+
},
|
|
2959
|
+
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
2960
|
+
fetch(handler) {
|
|
2961
|
+
const guard = protection.fetchGuard();
|
|
2962
|
+
return async (request, ...rest) => {
|
|
2963
|
+
const blocked = await guard(request);
|
|
2964
|
+
if (blocked) return blocked;
|
|
2965
|
+
const response = await handler(request, ...rest);
|
|
2966
|
+
return screenResp(response, reqContextFromFetch(request));
|
|
2967
|
+
};
|
|
2968
|
+
},
|
|
2969
|
+
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
2970
|
+
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
2971
|
+
express(exprOptions = {}) {
|
|
2972
|
+
return (req, res, next) => {
|
|
2973
|
+
let result;
|
|
2974
|
+
try {
|
|
2975
|
+
result = engine.evaluate(req);
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
onError?.(err);
|
|
2978
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2979
|
+
return next();
|
|
2980
|
+
}
|
|
2981
|
+
decide(
|
|
2982
|
+
"request",
|
|
2983
|
+
result,
|
|
2984
|
+
() => {
|
|
2985
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
2986
|
+
res.status(403).type("html").send(renderBlockPage({ url: req.originalUrl || req.url || "/", code: result?.rule?.id }));
|
|
2987
|
+
} else {
|
|
2988
|
+
res.status(403).json(blockBody(result));
|
|
2989
|
+
}
|
|
2990
|
+
},
|
|
2991
|
+
() => {
|
|
2992
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2993
|
+
next();
|
|
2994
|
+
},
|
|
2995
|
+
nodeRequestMeta(req)
|
|
2996
|
+
);
|
|
2997
|
+
};
|
|
2998
|
+
},
|
|
2999
|
+
// Node / Connect middleware — buffers the body itself (request phase).
|
|
3000
|
+
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
3001
|
+
node(nodeOptions = {}) {
|
|
3002
|
+
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
3003
|
+
return (req, res, next) => {
|
|
3004
|
+
const chunks = [];
|
|
3005
|
+
let size = 0;
|
|
3006
|
+
let overflow = false;
|
|
3007
|
+
req.on("data", (chunk) => {
|
|
3008
|
+
size += chunk.length;
|
|
3009
|
+
if (size > maxBytes) {
|
|
3010
|
+
overflow = true;
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
chunks.push(chunk);
|
|
3014
|
+
});
|
|
3015
|
+
req.on("error", (err) => {
|
|
3016
|
+
onError?.(err);
|
|
3017
|
+
next();
|
|
3018
|
+
});
|
|
3019
|
+
req.on("end", () => {
|
|
3020
|
+
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
3021
|
+
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
3022
|
+
let shaped;
|
|
3023
|
+
let result;
|
|
3024
|
+
try {
|
|
3025
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
3026
|
+
result = engine.evaluate(shaped);
|
|
3027
|
+
} catch (err) {
|
|
3028
|
+
onError?.(err);
|
|
3029
|
+
return next();
|
|
3030
|
+
}
|
|
3031
|
+
decide(
|
|
3032
|
+
"request",
|
|
3033
|
+
result,
|
|
3034
|
+
() => {
|
|
3035
|
+
res.statusCode = 403;
|
|
3036
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3037
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3038
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3039
|
+
} else {
|
|
3040
|
+
res.setHeader("content-type", "application/json");
|
|
3041
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
3042
|
+
}
|
|
3043
|
+
},
|
|
3044
|
+
() => {
|
|
3045
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
3046
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3047
|
+
next();
|
|
3048
|
+
},
|
|
3049
|
+
nodeRequestMeta(req)
|
|
3050
|
+
);
|
|
3051
|
+
});
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
if (options.egress) {
|
|
3056
|
+
protection.uninstallEgress = await installEgressGuard({
|
|
3057
|
+
shouldBlock: egressShouldBlock,
|
|
3058
|
+
onBlock: options.onEgressBlock,
|
|
3059
|
+
// Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
|
|
3060
|
+
// same skip accounting as the request/response phases.
|
|
3061
|
+
onSkip: ({ reason, detail }) => recordSkip("egress", reason, detail),
|
|
3062
|
+
dnsScreen: options.screenDns !== false,
|
|
3063
|
+
allowHosts: options.allowHosts
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
|
|
3067
|
+
const live = Boolean(options.siteUuid || options.token);
|
|
3068
|
+
const refreshSecret = options.refreshSecret ?? (typeof process !== "undefined" ? process.env.PATCHSTACK_REFRESH_SECRET : void 0);
|
|
3069
|
+
const refreshable = live && (options.refreshMs > 0 || Boolean(refreshSecret));
|
|
3070
|
+
let reporter = null;
|
|
3071
|
+
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
3072
|
+
try {
|
|
3073
|
+
({ reportManifest: reporter } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)));
|
|
3074
|
+
} catch (err) {
|
|
3075
|
+
onError?.(err);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
const runRefreshTick = async () => {
|
|
3079
|
+
if (reporter) {
|
|
3080
|
+
try {
|
|
3081
|
+
await reporter(cwd);
|
|
3082
|
+
} catch (err) {
|
|
3083
|
+
onError?.(err);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3087
|
+
mode = resolveMode(options, next);
|
|
3088
|
+
applyBundle(next);
|
|
3089
|
+
};
|
|
3090
|
+
if (live) {
|
|
3091
|
+
protection.refresh = () => runRefreshTick();
|
|
3092
|
+
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
3093
|
+
}
|
|
3094
|
+
if (options.refreshMs > 0 && live) {
|
|
3095
|
+
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
|
|
3096
|
+
protection.stopRefresh = () => {
|
|
3097
|
+
loop.stop();
|
|
3098
|
+
firewallLog?.stop();
|
|
3099
|
+
};
|
|
3100
|
+
} else if (firewallLog) {
|
|
3101
|
+
protection.stopRefresh = () => firewallLog.stop();
|
|
3102
|
+
}
|
|
3103
|
+
return protection;
|
|
3104
|
+
}
|
|
3105
|
+
function resolveMode(options, bundle) {
|
|
3106
|
+
const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
|
|
3107
|
+
if (env === "block" || env === "dry-run") return env;
|
|
3108
|
+
if (bundle?.enforcement === "block" || bundle?.enforcement === "dry-run") return bundle.enforcement;
|
|
3109
|
+
if (options?.mode === "block") return "block";
|
|
3110
|
+
if (options?.mode === "dry-run") return "dry-run";
|
|
3111
|
+
return "dry-run";
|
|
3112
|
+
}
|
|
3113
|
+
async function resolveApiKey(options) {
|
|
3114
|
+
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3115
|
+
if (typeof process !== "undefined") {
|
|
3116
|
+
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3117
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3118
|
+
}
|
|
3119
|
+
try {
|
|
3120
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3121
|
+
const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3122
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3123
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3124
|
+
const key = JSON.parse(raw)?.apiKey;
|
|
3125
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
3126
|
+
} catch {
|
|
3127
|
+
}
|
|
3128
|
+
return void 0;
|
|
3129
|
+
}
|
|
3130
|
+
async function resolvePulseAuth(options) {
|
|
3131
|
+
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
3132
|
+
if (typeof process !== "undefined") {
|
|
3133
|
+
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3134
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3135
|
+
}
|
|
3136
|
+
try {
|
|
3137
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
|
|
3138
|
+
const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3139
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3140
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3141
|
+
const key = JSON.parse(raw)?.pulseAuth;
|
|
3142
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
3143
|
+
} catch {
|
|
3144
|
+
}
|
|
3145
|
+
return resolveApiKey(options);
|
|
3146
|
+
}
|
|
3147
|
+
function byPhase(rules, phase) {
|
|
3148
|
+
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
3149
|
+
}
|
|
3150
|
+
function baseContentType(ct) {
|
|
3151
|
+
return String(ct || "").toLowerCase().split(";")[0].trim();
|
|
3152
|
+
}
|
|
3153
|
+
function screenableContentType(ct) {
|
|
3154
|
+
const base = baseContentType(ct);
|
|
3155
|
+
if (base === "text/event-stream") return "skip";
|
|
3156
|
+
if (base === "") return "text";
|
|
3157
|
+
if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return "text";
|
|
3158
|
+
if (base === "application/octet-stream") return "sniff";
|
|
3159
|
+
return "skip";
|
|
3160
|
+
}
|
|
3161
|
+
function looksBinary(bytes) {
|
|
3162
|
+
const n = Math.min(bytes.length, 512);
|
|
3163
|
+
let ctrl = 0;
|
|
3164
|
+
for (let i = 0; i < n; i++) {
|
|
3165
|
+
const b = bytes[i];
|
|
3166
|
+
if (b === 0) return true;
|
|
3167
|
+
if (b < 9 || b > 13 && b < 32) ctrl++;
|
|
3168
|
+
}
|
|
3169
|
+
return n > 0 && ctrl / n > 0.1;
|
|
3170
|
+
}
|
|
3171
|
+
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
3172
|
+
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
3173
|
+
const ct = response.headers?.get?.("content-type") || "";
|
|
3174
|
+
const kind = screenableContentType(ct);
|
|
3175
|
+
if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
|
|
3176
|
+
const sniff = kind === "sniff";
|
|
3177
|
+
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
3178
|
+
if (len && len > cap) return { skip: "body-cap" };
|
|
3179
|
+
let clone;
|
|
3180
|
+
try {
|
|
3181
|
+
clone = response.clone();
|
|
3182
|
+
} catch {
|
|
3183
|
+
return { skip: "clone-failed" };
|
|
3184
|
+
}
|
|
3185
|
+
const body = clone.body;
|
|
3186
|
+
if (body && typeof body.getReader === "function") {
|
|
3187
|
+
const reader = body.getReader();
|
|
3188
|
+
const chunks = [];
|
|
3189
|
+
let size = 0;
|
|
3190
|
+
let over = false;
|
|
3191
|
+
let sniffed = !sniff;
|
|
3192
|
+
try {
|
|
3193
|
+
for (; ; ) {
|
|
3194
|
+
const { done, value } = await reader.read();
|
|
3195
|
+
if (done) break;
|
|
3196
|
+
if (!value) continue;
|
|
3197
|
+
if (!sniffed) {
|
|
3198
|
+
sniffed = true;
|
|
3199
|
+
if (looksBinary(value)) return { skip: "binary-body" };
|
|
3200
|
+
}
|
|
3201
|
+
size += value.byteLength;
|
|
3202
|
+
if (over) continue;
|
|
3203
|
+
if (size > cap) {
|
|
3204
|
+
over = true;
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
chunks.push(value);
|
|
3208
|
+
}
|
|
3209
|
+
} catch {
|
|
3210
|
+
return { skip: "read-failed" };
|
|
3211
|
+
}
|
|
3212
|
+
if (over) return { skip: "body-cap" };
|
|
3213
|
+
try {
|
|
3214
|
+
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
|
|
3215
|
+
} catch {
|
|
3216
|
+
return { skip: "decode-failed" };
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
try {
|
|
3220
|
+
const text = await clone.text();
|
|
3221
|
+
if (text.length > cap) return { skip: "body-cap" };
|
|
3222
|
+
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
|
|
3223
|
+
return { text };
|
|
3224
|
+
} catch {
|
|
3225
|
+
return { skip: "read-failed" };
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
function concatBytes(chunks, total) {
|
|
3229
|
+
const out = new Uint8Array(total);
|
|
3230
|
+
let offset = 0;
|
|
3231
|
+
for (const c of chunks) {
|
|
3232
|
+
out.set(c, offset);
|
|
3233
|
+
offset += c.byteLength;
|
|
3234
|
+
}
|
|
3235
|
+
return out;
|
|
3236
|
+
}
|
|
3237
|
+
function headerObject(headers) {
|
|
3238
|
+
const out = {};
|
|
3239
|
+
headers?.forEach?.((v, k) => {
|
|
3240
|
+
out[k.toLowerCase()] = v;
|
|
3241
|
+
});
|
|
3242
|
+
const setCookies = headers?.getSetCookie?.();
|
|
3243
|
+
if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
|
|
3244
|
+
return out;
|
|
3245
|
+
}
|
|
3246
|
+
function hasSpanMutations(rule) {
|
|
3247
|
+
let found = false;
|
|
3248
|
+
const walk = (conds) => {
|
|
3249
|
+
for (const c of conds ?? []) {
|
|
3250
|
+
if (found) return;
|
|
3251
|
+
if (Array.isArray(c.rules)) walk(c.rules);
|
|
3252
|
+
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
3253
|
+
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
3254
|
+
}
|
|
3255
|
+
};
|
|
3256
|
+
walk(rule.rule_v2);
|
|
3257
|
+
return found;
|
|
3258
|
+
}
|
|
3259
|
+
function extractRedactors(rule) {
|
|
3260
|
+
const out = [];
|
|
3261
|
+
const walk = (conds) => {
|
|
3262
|
+
for (const c of conds ?? []) {
|
|
3263
|
+
if (Array.isArray(c.rules)) walk(c.rules);
|
|
3264
|
+
const m = c.match;
|
|
3265
|
+
if (!m) continue;
|
|
3266
|
+
if (m.type === "regex" && typeof m.value === "string") {
|
|
3267
|
+
const safe = safeRegExp(m.value);
|
|
3268
|
+
if (safe) {
|
|
3269
|
+
const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
|
|
3270
|
+
try {
|
|
3271
|
+
out.push({ re: new RegExp(safe.source, flags) });
|
|
3272
|
+
} catch {
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
3276
|
+
out.push({ literal: String(m.value) });
|
|
3277
|
+
} else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
|
|
3278
|
+
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
3279
|
+
for (const key of keys) {
|
|
3280
|
+
out.push({ jsonPath: String(key).split("."), condition: m.match });
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
};
|
|
3285
|
+
walk(rule.rule_v2);
|
|
3286
|
+
return out;
|
|
3287
|
+
}
|
|
3288
|
+
function htmlEscape(str) {
|
|
3289
|
+
return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3290
|
+
}
|
|
3291
|
+
function applyRedactors(body, redactors, mask, transform) {
|
|
3292
|
+
let out = body;
|
|
3293
|
+
for (const r of redactors) {
|
|
3294
|
+
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
3295
|
+
else if (r.literal) {
|
|
3296
|
+
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
3297
|
+
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
return out;
|
|
3301
|
+
}
|
|
3302
|
+
function isBodyParam(parameter) {
|
|
3303
|
+
return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
|
|
3304
|
+
}
|
|
3305
|
+
function conditionPredicate(condition) {
|
|
3306
|
+
if (!condition || !condition.type) return () => true;
|
|
3307
|
+
return (value) => {
|
|
3308
|
+
try {
|
|
3309
|
+
return matchValue(condition.type, value, condition.value, condition);
|
|
3310
|
+
} catch {
|
|
3311
|
+
return false;
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
}
|
|
3315
|
+
var DEFAULT_SCREEN_CAP = 512 * 1024;
|
|
3316
|
+
function responseScreenCap(rules) {
|
|
3317
|
+
let cap = DEFAULT_SCREEN_CAP;
|
|
3318
|
+
for (const r of rules ?? []) {
|
|
3319
|
+
if (r && r.bypass_limit === true) return Infinity;
|
|
3320
|
+
const n = Number(r && r.max_bytes);
|
|
3321
|
+
if (Number.isFinite(n) && n > cap) cap = n;
|
|
3322
|
+
}
|
|
3323
|
+
return cap;
|
|
3324
|
+
}
|
|
3325
|
+
function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
3326
|
+
if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
|
|
3327
|
+
const head = text.trimStart()[0];
|
|
3328
|
+
if (head !== "{" && head !== "[") return text;
|
|
3329
|
+
const preserved = preserveBigInts(text);
|
|
3330
|
+
let obj;
|
|
3331
|
+
try {
|
|
3332
|
+
obj = JSON.parse(preserved);
|
|
3333
|
+
} catch {
|
|
3334
|
+
return text;
|
|
3335
|
+
}
|
|
3336
|
+
let changed = false;
|
|
3337
|
+
for (const r of pathRedactors) {
|
|
3338
|
+
const pred = conditionPredicate(r.condition);
|
|
3339
|
+
walkLeaves(obj, r.jsonPath, (loc) => {
|
|
3340
|
+
try {
|
|
3341
|
+
if (pred(loc.value)) {
|
|
3342
|
+
loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
|
|
3343
|
+
changed = true;
|
|
3344
|
+
}
|
|
3345
|
+
} catch {
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
return changed ? restoreBigInts(JSON.stringify(obj)) : text;
|
|
3350
|
+
}
|
|
3351
|
+
var BIGINT_OPEN = "__PSBIGINT_9c2f__";
|
|
3352
|
+
var BIGINT_CLOSE = "__DNEGIB__";
|
|
3353
|
+
function preserveBigInts(text) {
|
|
3354
|
+
let out = "";
|
|
3355
|
+
let inStr = false;
|
|
3356
|
+
for (let i = 0; i < text.length; ) {
|
|
3357
|
+
const ch = text[i];
|
|
3358
|
+
if (inStr) {
|
|
3359
|
+
out += ch;
|
|
3360
|
+
if (ch === "\\") {
|
|
3361
|
+
out += text[i + 1] ?? "";
|
|
3362
|
+
i += 2;
|
|
3363
|
+
continue;
|
|
3364
|
+
}
|
|
3365
|
+
if (ch === '"') inStr = false;
|
|
3366
|
+
i++;
|
|
3367
|
+
continue;
|
|
3368
|
+
}
|
|
3369
|
+
if (ch === '"') {
|
|
3370
|
+
inStr = true;
|
|
3371
|
+
out += ch;
|
|
3372
|
+
i++;
|
|
3373
|
+
continue;
|
|
3374
|
+
}
|
|
3375
|
+
if (ch === "-" || ch >= "0" && ch <= "9") {
|
|
3376
|
+
let j = ch === "-" ? i + 1 : i;
|
|
3377
|
+
let digits = 0;
|
|
3378
|
+
while (j < text.length && text[j] >= "0" && text[j] <= "9") {
|
|
3379
|
+
digits++;
|
|
3380
|
+
j++;
|
|
3381
|
+
}
|
|
3382
|
+
const next = text[j];
|
|
3383
|
+
const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
|
|
3384
|
+
if (isIntToken && digits >= 16) {
|
|
3385
|
+
out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
|
|
3386
|
+
} else {
|
|
3387
|
+
out += text.slice(i, j || i + 1);
|
|
3388
|
+
}
|
|
3389
|
+
i = j > i ? j : i + 1;
|
|
3390
|
+
continue;
|
|
3391
|
+
}
|
|
3392
|
+
out += ch;
|
|
3393
|
+
i++;
|
|
3394
|
+
}
|
|
3395
|
+
return out;
|
|
3396
|
+
}
|
|
3397
|
+
function restoreBigInts(text) {
|
|
3398
|
+
if (!text.includes(BIGINT_OPEN)) return text;
|
|
3399
|
+
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
3400
|
+
}
|
|
3401
|
+
function isHeaderMutation(action) {
|
|
3402
|
+
return action === "set-header" || action === "remove-header" || action === "harden-cookie";
|
|
3403
|
+
}
|
|
3404
|
+
function applyHeaderMutation(headers, rule) {
|
|
3405
|
+
if (rule.action === "remove-header") {
|
|
3406
|
+
for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
|
|
3407
|
+
return;
|
|
3408
|
+
}
|
|
3409
|
+
if (rule.action === "set-header") {
|
|
3410
|
+
const ensure = rule.ensure === true;
|
|
3411
|
+
for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
|
|
3412
|
+
const key = String(name).toLowerCase();
|
|
3413
|
+
const present = headers[key] != null && headers[key] !== "";
|
|
3414
|
+
if (ensure && present) continue;
|
|
3415
|
+
headers[key] = String(value);
|
|
3416
|
+
}
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
if (rule.action === "harden-cookie") {
|
|
3420
|
+
const cookie = headers["set-cookie"];
|
|
3421
|
+
const flags = rule.cookie_flags ?? {};
|
|
3422
|
+
if (Array.isArray(cookie)) {
|
|
3423
|
+
headers["set-cookie"] = cookie.map((c) => typeof c === "string" ? hardenCookie(c, flags) : c);
|
|
3424
|
+
} else if (typeof cookie === "string") {
|
|
3425
|
+
headers["set-cookie"] = hardenCookie(cookie, flags);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3430
|
+
let out = String(cookie);
|
|
3431
|
+
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3432
|
+
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3433
|
+
if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
|
|
3434
|
+
return out;
|
|
3435
|
+
}
|
|
3436
|
+
function rebuildResponse(response, body, redactedHeaders) {
|
|
3437
|
+
const headers = new Headers(response.headers);
|
|
3438
|
+
headers.delete("content-length");
|
|
3439
|
+
if (redactedHeaders) {
|
|
3440
|
+
for (const [name, value] of Object.entries(redactedHeaders)) {
|
|
3441
|
+
if (value === null || value === void 0) {
|
|
3442
|
+
try {
|
|
3443
|
+
headers.delete(name);
|
|
3444
|
+
} catch {
|
|
3445
|
+
}
|
|
3446
|
+
} else if (typeof value === "string") {
|
|
3447
|
+
if (headers.get(name) !== value) {
|
|
3448
|
+
try {
|
|
3449
|
+
headers.set(name, value);
|
|
3450
|
+
} catch {
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
} else if (Array.isArray(value)) {
|
|
3454
|
+
try {
|
|
3455
|
+
headers.delete(name);
|
|
3456
|
+
for (const item of value) headers.append(name, String(item));
|
|
3457
|
+
} catch {
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
|
|
3463
|
+
return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
|
|
3464
|
+
}
|
|
3465
|
+
function leakResponse() {
|
|
3466
|
+
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|
|
3467
|
+
status: 500,
|
|
3468
|
+
headers: { "content-type": "application/json" }
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
var BLOCK_MESSAGE = "This request has been blocked by Patchstack.";
|
|
3472
|
+
function blockBody(result) {
|
|
3473
|
+
return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id };
|
|
3474
|
+
}
|
|
3475
|
+
function isDocumentNavigation(getHeader) {
|
|
3476
|
+
const dest = getHeader("sec-fetch-dest");
|
|
3477
|
+
if (dest) return dest === "document";
|
|
3478
|
+
return (getHeader("accept") || "").includes("text/html");
|
|
3479
|
+
}
|
|
3480
|
+
function blockResponse(result, request) {
|
|
3481
|
+
if (request && isDocumentNavigation((n) => request.headers.get(n))) {
|
|
3482
|
+
return new Response(renderBlockPage({ url: request.url, code: result?.rule?.id }), {
|
|
3483
|
+
status: 403,
|
|
3484
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
3485
|
+
});
|
|
3486
|
+
}
|
|
3487
|
+
return new Response(JSON.stringify(blockBody(result)), {
|
|
3488
|
+
status: 403,
|
|
3489
|
+
headers: { "content-type": "application/json" }
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
3493
|
+
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
3494
|
+
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
3495
|
+
}
|
|
3496
|
+
function fetchRequestMeta(request) {
|
|
3497
|
+
if (!request) return {};
|
|
3498
|
+
let path = null;
|
|
3499
|
+
try {
|
|
3500
|
+
path = new URL(request.url).pathname;
|
|
3501
|
+
} catch {
|
|
3502
|
+
path = typeof request.url === "string" ? request.url : null;
|
|
3503
|
+
}
|
|
3504
|
+
return {
|
|
3505
|
+
method: request.method ?? null,
|
|
3506
|
+
path,
|
|
3507
|
+
ip: request.headers?.get?.("x-forwarded-for") ?? null,
|
|
3508
|
+
userAgent: request.headers?.get?.("user-agent") ?? null
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
function nodeRequestMeta(req) {
|
|
3512
|
+
if (!req) return {};
|
|
3513
|
+
const headers = req.headers ?? {};
|
|
3514
|
+
const ua = headers["user-agent"] ?? headers["User-Agent"];
|
|
3515
|
+
const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
|
|
3516
|
+
return {
|
|
3517
|
+
method: req.method ?? null,
|
|
3518
|
+
path: req.originalUrl || req.url || null,
|
|
3519
|
+
ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
|
|
3520
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
export {
|
|
3524
|
+
GUARD_PATH,
|
|
3525
|
+
PulseRuleClient,
|
|
3526
|
+
createProtection,
|
|
3527
|
+
createServerFnGuard,
|
|
3528
|
+
createSupabaseGuard
|
|
3529
|
+
};
|
|
3530
|
+
//# sourceMappingURL=protect.edge.js.map
|