@patchstack/connect 0.3.28 → 0.3.30
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 +37 -21
- package/README.md +24 -10
- package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
- package/dist/chunk-LLKP5EJS.js.map +1 -0
- package/dist/cli.js +1267 -224
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +419 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -9
- package/dist/index.d.ts +28 -9
- package/dist/index.js +412 -97
- package/dist/index.js.map +1 -1
- package/dist/protect/templates/astro-middleware.ts +33 -13
- package/dist/protect/templates/demo-rules.json +2 -3
- package/dist/protect/templates/express-guard.cjs +26 -15
- package/dist/protect/templates/express-guard.js +26 -15
- package/dist/protect/templates/express-guard.ts +33 -16
- package/dist/protect/templates/fastify-plugin.cjs +33 -11
- package/dist/protect/templates/fastify-plugin.js +33 -11
- package/dist/protect/templates/fastify-plugin.ts +40 -12
- package/dist/protect/templates/generic-guard.cjs +28 -12
- package/dist/protect/templates/generic-guard.js +28 -13
- package/dist/protect/templates/generic-guard.ts +41 -20
- package/dist/protect/templates/guard.ts +47 -27
- package/dist/protect/templates/next-middleware.ts +29 -12
- package/dist/protect/templates/nuxt-middleware.ts +29 -12
- package/dist/protect/templates/rules.json +2 -2
- package/dist/protect/templates/sveltekit-hooks.ts +33 -13
- package/dist/protect.cjs +1039 -235
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +39 -11
- package/dist/protect.edge.js +663 -132
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +665 -134
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
- package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
- package/package.json +5 -2
- package/dist/chunk-MJOTFUDE.js.map +0 -1
- package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/protect.edge.js
CHANGED
|
@@ -160,6 +160,45 @@ function base64DecodeUtf8(value) {
|
|
|
160
160
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
161
161
|
return new TextDecoder().decode(bytes);
|
|
162
162
|
}
|
|
163
|
+
var NAMED_ENTITIES = {
|
|
164
|
+
lt: "<",
|
|
165
|
+
gt: ">",
|
|
166
|
+
amp: "&",
|
|
167
|
+
quot: '"',
|
|
168
|
+
apos: "'",
|
|
169
|
+
"#39": "'",
|
|
170
|
+
nbsp: "\xA0",
|
|
171
|
+
sol: "/",
|
|
172
|
+
bsol: "\\",
|
|
173
|
+
colon: ":",
|
|
174
|
+
lpar: "(",
|
|
175
|
+
rpar: ")",
|
|
176
|
+
equals: "=",
|
|
177
|
+
grave: "`",
|
|
178
|
+
Tab: " ",
|
|
179
|
+
NewLine: "\n",
|
|
180
|
+
semi: ";",
|
|
181
|
+
excl: "!",
|
|
182
|
+
num: "#",
|
|
183
|
+
dollar: "$",
|
|
184
|
+
percnt: "%",
|
|
185
|
+
ast: "*"
|
|
186
|
+
};
|
|
187
|
+
function decodeHtmlEntities(input) {
|
|
188
|
+
return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
189
|
+
if (body[0] === "#") {
|
|
190
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
191
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
192
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
|
|
193
|
+
try {
|
|
194
|
+
return String.fromCodePoint(code);
|
|
195
|
+
} catch {
|
|
196
|
+
return whole;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
163
202
|
var RequestResolver = class {
|
|
164
203
|
#req;
|
|
165
204
|
#cookies;
|
|
@@ -282,6 +321,8 @@ var RequestResolver = class {
|
|
|
282
321
|
} catch {
|
|
283
322
|
return value;
|
|
284
323
|
}
|
|
324
|
+
case "htmlentitydecode":
|
|
325
|
+
return decodeHtmlEntities(String(value));
|
|
285
326
|
case "intval":
|
|
286
327
|
return parseInt(String(value), 10) || 0;
|
|
287
328
|
case "getArrayValues":
|
|
@@ -2198,25 +2239,428 @@ function toEnvelope(value) {
|
|
|
2198
2239
|
return null;
|
|
2199
2240
|
}
|
|
2200
2241
|
|
|
2201
|
-
// src/protect/rules/
|
|
2202
|
-
var
|
|
2242
|
+
// src/protect/rules/contract.js
|
|
2243
|
+
var SOURCES = Object.freeze({
|
|
2244
|
+
raw: Object.freeze({ keyed: false }),
|
|
2245
|
+
all: Object.freeze({ keyed: false }),
|
|
2246
|
+
false: Object.freeze({ keyed: false }),
|
|
2247
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
2248
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2249
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2250
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2251
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2252
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
2253
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
2254
|
+
files: Object.freeze({
|
|
2255
|
+
keyed: true,
|
|
2256
|
+
keys: "any",
|
|
2257
|
+
wildcard: true,
|
|
2258
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
2259
|
+
}),
|
|
2260
|
+
server: Object.freeze({
|
|
2261
|
+
keyed: true,
|
|
2262
|
+
keys: Object.freeze([
|
|
2263
|
+
"REQUEST_URI",
|
|
2264
|
+
"REQUEST_METHOD",
|
|
2265
|
+
"HTTP_USER_AGENT",
|
|
2266
|
+
"HTTP_REFERER",
|
|
2267
|
+
"HTTP_HOST",
|
|
2268
|
+
"REMOTE_ADDR",
|
|
2269
|
+
"ip",
|
|
2270
|
+
"CONTENT_TYPE",
|
|
2271
|
+
"CONTENT_LENGTH"
|
|
2272
|
+
]),
|
|
2273
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
2274
|
+
}),
|
|
2275
|
+
response: Object.freeze({
|
|
2276
|
+
keyed: true,
|
|
2277
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
2278
|
+
key_prefixes: Object.freeze(["header."])
|
|
2279
|
+
}),
|
|
2280
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
2281
|
+
});
|
|
2282
|
+
var MATCH_TYPES = Object.freeze([
|
|
2283
|
+
"array_in_array",
|
|
2284
|
+
"array_key_value",
|
|
2285
|
+
"contains",
|
|
2286
|
+
"cors_reflected",
|
|
2287
|
+
"cross_origin",
|
|
2288
|
+
"ctype_alnum",
|
|
2289
|
+
"ctype_digit",
|
|
2290
|
+
"ctype_special",
|
|
2291
|
+
"equals",
|
|
2292
|
+
"equals_strict",
|
|
2293
|
+
"hostname",
|
|
2294
|
+
"in_array",
|
|
2295
|
+
"inline_js_xss",
|
|
2296
|
+
"inline_xss",
|
|
2297
|
+
"internal_host",
|
|
2298
|
+
"is_numeric",
|
|
2299
|
+
"isset",
|
|
2300
|
+
"less_than",
|
|
2301
|
+
"more_than",
|
|
2302
|
+
"not_contains",
|
|
2303
|
+
"not_in_array",
|
|
2304
|
+
"off_origin",
|
|
2305
|
+
"quotes",
|
|
2306
|
+
"regex",
|
|
2307
|
+
"stripos"
|
|
2308
|
+
]);
|
|
2309
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
2310
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
2311
|
+
"isset",
|
|
2312
|
+
"quotes",
|
|
2313
|
+
"is_numeric",
|
|
2314
|
+
"ctype_alnum",
|
|
2315
|
+
"ctype_digit",
|
|
2316
|
+
"ctype_special",
|
|
2317
|
+
"inline_xss",
|
|
2318
|
+
"inline_js_xss",
|
|
2319
|
+
"internal_host",
|
|
2320
|
+
"cross_origin",
|
|
2321
|
+
"off_origin",
|
|
2322
|
+
"cors_reflected"
|
|
2323
|
+
]);
|
|
2324
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
2325
|
+
"non-empty-string",
|
|
2326
|
+
"regex-literal",
|
|
2327
|
+
"string-or-string-list",
|
|
2328
|
+
"scalar",
|
|
2329
|
+
"string",
|
|
2330
|
+
"numeric",
|
|
2331
|
+
"scalar-or-non-empty-list",
|
|
2332
|
+
"non-empty-list",
|
|
2333
|
+
"object",
|
|
2334
|
+
"non-empty-object",
|
|
2335
|
+
"non-empty-string-list",
|
|
2336
|
+
"header-name-map",
|
|
2337
|
+
"header-name-list",
|
|
2338
|
+
"cookie-flags"
|
|
2339
|
+
]);
|
|
2340
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
2341
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
2342
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2343
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2344
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
2345
|
+
array_key_value: Object.freeze({
|
|
2346
|
+
required: Object.freeze(["key", "match"]),
|
|
2347
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
2348
|
+
}),
|
|
2349
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
2350
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2351
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2352
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2353
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
2354
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
2355
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
2356
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
2357
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2358
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2359
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2360
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
2361
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
2362
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
2363
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
2364
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
2365
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
2366
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
2367
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
2368
|
+
});
|
|
2369
|
+
function operandShapeProblem(shape, value) {
|
|
2370
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
2371
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
2372
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
2373
|
+
switch (shape) {
|
|
2374
|
+
case "non-empty-string":
|
|
2375
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
2376
|
+
case "regex-literal":
|
|
2377
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
2378
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
2379
|
+
case "string-or-string-list":
|
|
2380
|
+
if (isFilledString(value)) return null;
|
|
2381
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
2382
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
2383
|
+
case "scalar":
|
|
2384
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
2385
|
+
case "string":
|
|
2386
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
2387
|
+
case "numeric":
|
|
2388
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
2389
|
+
case "scalar-or-non-empty-list":
|
|
2390
|
+
if (isScalar(value)) return null;
|
|
2391
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
2392
|
+
return "must be a scalar, or a non-empty list of them";
|
|
2393
|
+
case "non-empty-list":
|
|
2394
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
2395
|
+
case "header-name-map": {
|
|
2396
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2397
|
+
const names = Object.keys(value);
|
|
2398
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
2399
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
2400
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2401
|
+
}
|
|
2402
|
+
case "header-name-list": {
|
|
2403
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
2404
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
2405
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2406
|
+
}
|
|
2407
|
+
case "cookie-flags": {
|
|
2408
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2409
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
2410
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
2411
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
2412
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
2413
|
+
return `flag "${flag}" must be true or false`;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const sameSite = value.sameSite;
|
|
2417
|
+
const sameSiteOff = sameSite === false;
|
|
2418
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
2419
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
2420
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
2424
|
+
return "turns off every flag, so it would harden nothing";
|
|
2425
|
+
}
|
|
2426
|
+
return null;
|
|
2427
|
+
}
|
|
2428
|
+
case "object":
|
|
2429
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
2430
|
+
case "non-empty-object":
|
|
2431
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
2432
|
+
case "non-empty-string-list":
|
|
2433
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
2434
|
+
default:
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
var MUTATIONS = Object.freeze([
|
|
2439
|
+
"base64_decode",
|
|
2440
|
+
"getArrayValues",
|
|
2441
|
+
"htmlentitydecode",
|
|
2442
|
+
"intval",
|
|
2443
|
+
"json_decode",
|
|
2444
|
+
"json_encode",
|
|
2445
|
+
"urldecode"
|
|
2446
|
+
]);
|
|
2447
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
2448
|
+
var ACTIONS = Object.freeze([
|
|
2449
|
+
"block",
|
|
2450
|
+
"redact",
|
|
2451
|
+
"encode",
|
|
2452
|
+
"set-header",
|
|
2453
|
+
"remove-header",
|
|
2454
|
+
"harden-cookie"
|
|
2455
|
+
]);
|
|
2456
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
2457
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
2458
|
+
"id",
|
|
2459
|
+
"rule_id",
|
|
2460
|
+
"title",
|
|
2461
|
+
"category",
|
|
2462
|
+
"phase",
|
|
2463
|
+
"action",
|
|
2464
|
+
"rule_v2",
|
|
2465
|
+
"when",
|
|
2466
|
+
"message",
|
|
2467
|
+
"enforcement",
|
|
2468
|
+
"source_revision",
|
|
2469
|
+
"prefilter",
|
|
2470
|
+
"max_bytes",
|
|
2471
|
+
"bypass_limit",
|
|
2472
|
+
"set_headers",
|
|
2473
|
+
"remove_headers",
|
|
2474
|
+
"cookie_flags",
|
|
2475
|
+
"ensure"
|
|
2476
|
+
]);
|
|
2477
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
2478
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
2479
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2480
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2481
|
+
"set-header": Object.freeze({
|
|
2482
|
+
required: Object.freeze(["set_headers"]),
|
|
2483
|
+
defaulted: Object.freeze(["ensure"]),
|
|
2484
|
+
phases: Object.freeze(["response"]),
|
|
2485
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
2486
|
+
}),
|
|
2487
|
+
"remove-header": Object.freeze({
|
|
2488
|
+
required: Object.freeze(["remove_headers"]),
|
|
2489
|
+
defaulted: Object.freeze([]),
|
|
2490
|
+
phases: Object.freeze(["response"]),
|
|
2491
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
2492
|
+
}),
|
|
2493
|
+
"harden-cookie": Object.freeze({
|
|
2494
|
+
required: Object.freeze([]),
|
|
2495
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
2496
|
+
phases: Object.freeze(["response"]),
|
|
2497
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
2498
|
+
})
|
|
2499
|
+
});
|
|
2500
|
+
var LIMITS = Object.freeze({
|
|
2203
2501
|
maxRules: 5e3,
|
|
2204
2502
|
maxWhitelists: 2e3,
|
|
2205
2503
|
maxConditionsPerRule: 250,
|
|
2206
2504
|
maxNestingDepth: 12,
|
|
2207
2505
|
maxRegexLength: 1e3,
|
|
2208
2506
|
maxValueLength: 8192
|
|
2209
|
-
};
|
|
2210
|
-
var
|
|
2211
|
-
|
|
2507
|
+
});
|
|
2508
|
+
var GROUP_PARAMETER = "rules";
|
|
2509
|
+
function isGroup(condition) {
|
|
2510
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
2511
|
+
}
|
|
2512
|
+
function conditionShapeProblem(condition) {
|
|
2513
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
2514
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
2515
|
+
const nested = Array.isArray(condition.rules);
|
|
2516
|
+
if (named && !nested) {
|
|
2517
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
2518
|
+
}
|
|
2519
|
+
if (nested && !named) {
|
|
2520
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2521
|
+
}
|
|
2522
|
+
if (named && nested) {
|
|
2523
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2524
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
2525
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
2526
|
+
}
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
function parameterProblem(parameter) {
|
|
2530
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
2531
|
+
if (Array.isArray(parameter)) {
|
|
2532
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
2533
|
+
for (const member of parameter) {
|
|
2534
|
+
const problem = parameterProblem(member);
|
|
2535
|
+
if (problem !== null) return problem;
|
|
2536
|
+
}
|
|
2537
|
+
return null;
|
|
2538
|
+
}
|
|
2539
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
2540
|
+
if (parameter === GROUP_PARAMETER) {
|
|
2541
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
2542
|
+
}
|
|
2543
|
+
const dot = parameter.indexOf(".");
|
|
2544
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
2545
|
+
const source = SOURCES[name];
|
|
2546
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
2547
|
+
if (dot === -1) {
|
|
2548
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
2549
|
+
}
|
|
2550
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
2551
|
+
const key = parameter.slice(dot + 1);
|
|
2552
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
2553
|
+
return keyProblem(name, source, key);
|
|
2554
|
+
}
|
|
2555
|
+
function keyProblem(name, source, key) {
|
|
2556
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
2557
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
2558
|
+
}
|
|
2559
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
2560
|
+
if (source.keys === "any") return null;
|
|
2561
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
2562
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
2563
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
2564
|
+
}
|
|
2565
|
+
const accepted = [
|
|
2566
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
2567
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2568
|
+
].join(", ");
|
|
2569
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2570
|
+
}
|
|
2571
|
+
function matchProblem(match, parameter) {
|
|
2572
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
2573
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
2574
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
2575
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
2576
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
2577
|
+
return `match type "${match.type}" needs a parameter`;
|
|
2578
|
+
}
|
|
2579
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
2580
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
2581
|
+
}
|
|
2582
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
2583
|
+
if (operands) {
|
|
2584
|
+
for (const required of operands.required) {
|
|
2585
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
2586
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
2590
|
+
if (match[operand] === void 0) continue;
|
|
2591
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
2592
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
2593
|
+
}
|
|
2594
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
2595
|
+
return `match type "${match.type}" needs a value`;
|
|
2596
|
+
}
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
function mutationsProblem(mutations) {
|
|
2600
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
2601
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
2602
|
+
for (const mutation of mutations) {
|
|
2603
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
2604
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
function whenProblem(when) {
|
|
2610
|
+
if (when === void 0 || when === null) return null;
|
|
2611
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
2612
|
+
const keys = Object.keys(when);
|
|
2613
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
2614
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
2615
|
+
if (unknown.length > 0) {
|
|
2616
|
+
return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
|
|
2617
|
+
}
|
|
2618
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
2619
|
+
return "when.path must be a non-empty string";
|
|
2620
|
+
}
|
|
2621
|
+
if ("method" in when) {
|
|
2622
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
2623
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
2624
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
2625
|
+
}
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
function isNonEmptyString(value) {
|
|
2629
|
+
return typeof value === "string" && value.trim() !== "";
|
|
2630
|
+
}
|
|
2631
|
+
function actionProblem(action, rule) {
|
|
2632
|
+
if (action === void 0 || action === null) return null;
|
|
2633
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2634
|
+
const spec = ACTION_PROPERTIES[action];
|
|
2635
|
+
for (const required of spec?.required ?? []) {
|
|
2636
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2637
|
+
return `action "${action}" needs "${required}"`;
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2641
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2642
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2643
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2644
|
+
}
|
|
2645
|
+
const phase = rule?.phase ?? "request";
|
|
2646
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2647
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2648
|
+
}
|
|
2649
|
+
return null;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// src/protect/rules/validate.js
|
|
2653
|
+
var LIMITS2 = LIMITS;
|
|
2654
|
+
var PHASES2 = new Set(PHASES);
|
|
2655
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
2212
2656
|
function validateBundle(bundle, opts = {}) {
|
|
2213
2657
|
const rejected = [];
|
|
2214
2658
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2215
2659
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2216
2660
|
const firewall = [];
|
|
2217
2661
|
for (const rule of inFirewall) {
|
|
2218
|
-
if (firewall.length >=
|
|
2219
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
2662
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
2663
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2220
2664
|
continue;
|
|
2221
2665
|
}
|
|
2222
2666
|
const reason = ruleProblem(rule);
|
|
@@ -2225,8 +2669,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
2225
2669
|
}
|
|
2226
2670
|
const whitelists = [];
|
|
2227
2671
|
for (const wl of inWhitelists) {
|
|
2228
|
-
if (whitelists.length >=
|
|
2229
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
2672
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
2673
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
2230
2674
|
continue;
|
|
2231
2675
|
}
|
|
2232
2676
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -2248,8 +2692,11 @@ function idOf(rule) {
|
|
|
2248
2692
|
}
|
|
2249
2693
|
function ruleProblem(rule) {
|
|
2250
2694
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
2251
|
-
if (rule.phase !== void 0 && !
|
|
2252
|
-
|
|
2695
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2696
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
2697
|
+
if (actionReason) return actionReason;
|
|
2698
|
+
const scopeReason = whenProblem(rule.when);
|
|
2699
|
+
if (scopeReason) return scopeReason;
|
|
2253
2700
|
const capOverride = rule.max_bytes;
|
|
2254
2701
|
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
2255
2702
|
return conditionsProblem(rule.rule_v2);
|
|
@@ -2257,28 +2704,33 @@ function ruleProblem(rule) {
|
|
|
2257
2704
|
function conditionsProblem(conditions, depth = 0) {
|
|
2258
2705
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2259
2706
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2260
|
-
if (depth >
|
|
2261
|
-
if (conditions.length >
|
|
2262
|
-
return `more than ${
|
|
2707
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
2708
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
2709
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
2263
2710
|
}
|
|
2264
2711
|
for (const c of conditions) {
|
|
2265
|
-
|
|
2266
|
-
if (
|
|
2712
|
+
const shapeReason = conditionShapeProblem(c);
|
|
2713
|
+
if (shapeReason) return shapeReason;
|
|
2714
|
+
if (isGroup(c)) {
|
|
2267
2715
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2268
2716
|
if (nested) return nested;
|
|
2269
2717
|
continue;
|
|
2270
2718
|
}
|
|
2719
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
2720
|
+
if (parameterReason) return parameterReason;
|
|
2721
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
2722
|
+
if (mutationReason) return mutationReason;
|
|
2723
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
2724
|
+
if (matchReason) return matchReason;
|
|
2271
2725
|
const m = c.match;
|
|
2272
|
-
if (!m || typeof m !== "object") return "condition has no match object";
|
|
2273
|
-
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
2274
2726
|
if (m.type === "regex") {
|
|
2275
2727
|
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2276
|
-
if (m.value.length >
|
|
2277
|
-
} else if (typeof m.value === "string" && m.value.length >
|
|
2278
|
-
return `match.value longer than ${
|
|
2728
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2729
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2730
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2279
2731
|
}
|
|
2280
2732
|
if (m.match) {
|
|
2281
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2733
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2282
2734
|
if (nested) return nested;
|
|
2283
2735
|
}
|
|
2284
2736
|
}
|
|
@@ -2306,64 +2758,67 @@ function reportRejections(rejected, options, label) {
|
|
|
2306
2758
|
`${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" : ""}`
|
|
2307
2759
|
), "onError");
|
|
2308
2760
|
}
|
|
2761
|
+
function fromSource(bundle, reason) {
|
|
2762
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
2763
|
+
}
|
|
2309
2764
|
async function resolveRules(options, store, ctx = {}) {
|
|
2310
2765
|
const timeoutMs = ctx.timeoutMs;
|
|
2311
2766
|
if (options.siteUuid) {
|
|
2312
2767
|
const prior = await store.read();
|
|
2313
2768
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2314
2769
|
const res = await client.getRules();
|
|
2315
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2770
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2316
2771
|
if (res.success && !res.notModified) {
|
|
2317
2772
|
const rejected = liveUpdateRejections(res, options);
|
|
2318
2773
|
if (rejected.length > 0) {
|
|
2319
2774
|
reportRejections(rejected, options, "rule update rejected");
|
|
2320
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2321
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2322
|
-
return emptyBundle();
|
|
2775
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2776
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2777
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2323
2778
|
}
|
|
2324
2779
|
const bundle = normalizeBundle(res, options);
|
|
2325
2780
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2326
|
-
return bundle;
|
|
2781
|
+
return fromSource(bundle);
|
|
2327
2782
|
}
|
|
2328
2783
|
if (prior?.bundle) {
|
|
2329
2784
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2330
|
-
return normalizeBundle(prior.bundle, options);
|
|
2785
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2331
2786
|
}
|
|
2332
2787
|
if (options.rules) {
|
|
2333
2788
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2334
|
-
return normalizeBundle(options.rules, options);
|
|
2789
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
2335
2790
|
}
|
|
2336
2791
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2337
|
-
return emptyBundle();
|
|
2792
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2338
2793
|
}
|
|
2339
2794
|
if (options.token) {
|
|
2340
2795
|
const prior = await store.read();
|
|
2341
2796
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2342
2797
|
const res = await client.getRules();
|
|
2343
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2798
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2344
2799
|
if (res.success && !res.notModified) {
|
|
2345
2800
|
const rejected = liveUpdateRejections(res, options);
|
|
2346
2801
|
if (rejected.length > 0) {
|
|
2347
2802
|
reportRejections(rejected, options, "rule update rejected");
|
|
2348
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2349
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2350
|
-
return emptyBundle();
|
|
2803
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2804
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2805
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2351
2806
|
}
|
|
2352
2807
|
const bundle = normalizeBundle(res, options);
|
|
2353
2808
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2354
|
-
return bundle;
|
|
2809
|
+
return fromSource(bundle);
|
|
2355
2810
|
}
|
|
2356
2811
|
if (prior?.bundle) {
|
|
2357
2812
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2358
|
-
return normalizeBundle(prior.bundle, options);
|
|
2813
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2359
2814
|
}
|
|
2360
2815
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2361
|
-
return emptyBundle();
|
|
2816
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2362
2817
|
}
|
|
2363
2818
|
if (options.rules) {
|
|
2364
|
-
return normalizeBundle(options.rules, options);
|
|
2819
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
2365
2820
|
}
|
|
2366
|
-
return emptyBundle();
|
|
2821
|
+
return fromSource(emptyBundle());
|
|
2367
2822
|
}
|
|
2368
2823
|
function normalizeBundle(b, options = {}) {
|
|
2369
2824
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -2416,8 +2871,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2416
2871
|
const run = async () => {
|
|
2417
2872
|
if (stopped) return;
|
|
2418
2873
|
try {
|
|
2419
|
-
await tick();
|
|
2420
|
-
|
|
2874
|
+
const status = await tick();
|
|
2875
|
+
if (status && status.ok === false) failures++;
|
|
2876
|
+
else failures = 0;
|
|
2421
2877
|
} catch (err) {
|
|
2422
2878
|
failures++;
|
|
2423
2879
|
notify(onError, err, "onError");
|
|
@@ -2444,7 +2900,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2444
2900
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
2445
2901
|
let refreshed = true;
|
|
2446
2902
|
try {
|
|
2447
|
-
await tick();
|
|
2903
|
+
const status = await tick();
|
|
2904
|
+
if (status && status.ok === false) refreshed = false;
|
|
2448
2905
|
} catch {
|
|
2449
2906
|
refreshed = false;
|
|
2450
2907
|
}
|
|
@@ -2472,6 +2929,12 @@ function ruleParameters(rule) {
|
|
|
2472
2929
|
walk(rule?.rule_v2);
|
|
2473
2930
|
return [...out];
|
|
2474
2931
|
}
|
|
2932
|
+
function revisionOf(rule) {
|
|
2933
|
+
const revision = rule?.source_revision;
|
|
2934
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
2935
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
2936
|
+
return null;
|
|
2937
|
+
}
|
|
2475
2938
|
function routeOf(path) {
|
|
2476
2939
|
if (typeof path !== "string" || path === "") return null;
|
|
2477
2940
|
const cut = path.search(/[?#]/);
|
|
@@ -2480,16 +2943,30 @@ function routeOf(path) {
|
|
|
2480
2943
|
function createDetectionReporter(opts) {
|
|
2481
2944
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2482
2945
|
if (!siteUuid) {
|
|
2483
|
-
return {
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2946
|
+
return {
|
|
2947
|
+
record() {
|
|
2948
|
+
},
|
|
2949
|
+
flush() {
|
|
2950
|
+
},
|
|
2951
|
+
stop() {
|
|
2952
|
+
},
|
|
2953
|
+
setRulesEtag() {
|
|
2954
|
+
},
|
|
2955
|
+
dropped: () => 0,
|
|
2956
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
2957
|
+
};
|
|
2487
2958
|
}
|
|
2488
2959
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2489
2960
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2490
2961
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2491
2962
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2492
2963
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
2964
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
2965
|
+
let sent = 0;
|
|
2966
|
+
let delivered = 0;
|
|
2967
|
+
let failed = 0;
|
|
2968
|
+
let droppedTotal = 0;
|
|
2969
|
+
let lastDeliveredAt = null;
|
|
2493
2970
|
let queue = [];
|
|
2494
2971
|
let timer = null;
|
|
2495
2972
|
let stopped = false;
|
|
@@ -2503,6 +2980,8 @@ function createDetectionReporter(opts) {
|
|
|
2503
2980
|
const batch = queue.splice(0, MAX_BATCH);
|
|
2504
2981
|
const droppedWith = dropped;
|
|
2505
2982
|
dropped = 0;
|
|
2983
|
+
droppedTotal += droppedWith;
|
|
2984
|
+
sent += batch.length;
|
|
2506
2985
|
void (async () => {
|
|
2507
2986
|
try {
|
|
2508
2987
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -2511,15 +2990,22 @@ function createDetectionReporter(opts) {
|
|
|
2511
2990
|
"Content-Type": "application/json",
|
|
2512
2991
|
Accept: "application/json",
|
|
2513
2992
|
"User-Agent": "@patchstack/connect",
|
|
2514
|
-
// Same credential path as the rules fetch
|
|
2515
|
-
//
|
|
2993
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
2994
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
2995
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
2996
|
+
// posting into a 401.
|
|
2516
2997
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2517
2998
|
},
|
|
2518
2999
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2519
3000
|
});
|
|
2520
|
-
if (res &&
|
|
2521
|
-
|
|
3001
|
+
if (res && res.ok) {
|
|
3002
|
+
delivered += batch.length;
|
|
3003
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3004
|
+
} else {
|
|
3005
|
+
failed += batch.length;
|
|
3006
|
+
}
|
|
2522
3007
|
} catch {
|
|
3008
|
+
failed += batch.length;
|
|
2523
3009
|
}
|
|
2524
3010
|
})();
|
|
2525
3011
|
};
|
|
@@ -2548,7 +3034,12 @@ function createDetectionReporter(opts) {
|
|
|
2548
3034
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2549
3035
|
// saw traffic it would have stopped.
|
|
2550
3036
|
enforced: detection.mode === "block",
|
|
2551
|
-
rules_etag:
|
|
3037
|
+
rules_etag: rulesEtag,
|
|
3038
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
3039
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
3040
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
3041
|
+
// bundle carried none.
|
|
3042
|
+
rule_revision: revisionOf(detection.rule),
|
|
2552
3043
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2553
3044
|
});
|
|
2554
3045
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -2562,7 +3053,24 @@ function createDetectionReporter(opts) {
|
|
|
2562
3053
|
stopped = true;
|
|
2563
3054
|
flush();
|
|
2564
3055
|
},
|
|
2565
|
-
|
|
3056
|
+
/**
|
|
3057
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
3058
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
3059
|
+
*
|
|
3060
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
3061
|
+
* the only moment the two are known to agree.
|
|
3062
|
+
*
|
|
3063
|
+
* @param {string | null | undefined} next
|
|
3064
|
+
*/
|
|
3065
|
+
setRulesEtag(next) {
|
|
3066
|
+
rulesEtag = next ?? null;
|
|
3067
|
+
},
|
|
3068
|
+
dropped: () => dropped,
|
|
3069
|
+
/**
|
|
3070
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
3071
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
3072
|
+
*/
|
|
3073
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
2566
3074
|
};
|
|
2567
3075
|
}
|
|
2568
3076
|
|
|
@@ -2827,22 +3335,32 @@ async function createProtection(options = {}) {
|
|
|
2827
3335
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2828
3336
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2829
3337
|
if (options.siteUuid && !pulseAuth) {
|
|
2830
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
3338
|
+
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates will be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
|
|
2831
3339
|
notify(onError, new Error(message), "onError");
|
|
2832
3340
|
console.warn(message);
|
|
2833
3341
|
}
|
|
2834
3342
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
3343
|
+
let detectionReporting = "off";
|
|
2835
3344
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
3345
|
+
if (!pulseAuth) {
|
|
3346
|
+
detectionReporting = "unavailable-no-credential";
|
|
3347
|
+
const message = "Patchstack: detection reporting is enabled for site " + options.siteUuid + " but no API credential resolved, so no report could be delivered. Reporting is off.";
|
|
3348
|
+
notify(onError, new Error(message), "onError");
|
|
3349
|
+
console.warn(message);
|
|
3350
|
+
} else {
|
|
3351
|
+
detectionReporting = "on";
|
|
3352
|
+
detections = createDetectionReporter({
|
|
3353
|
+
siteUuid: options.siteUuid,
|
|
3354
|
+
baseUrl: options.pulseRulesUrl,
|
|
3355
|
+
pulseAuth,
|
|
3356
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
3357
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
3358
|
+
// see the refresh tick below.
|
|
3359
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
3360
|
+
fetchImpl: options.fetchImpl,
|
|
3361
|
+
flushMs: options.detectionFlushMs
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
2846
3364
|
}
|
|
2847
3365
|
let mode = resolveMode(options, bundle);
|
|
2848
3366
|
let requestRules;
|
|
@@ -3176,11 +3694,17 @@ async function createProtection(options = {}) {
|
|
|
3176
3694
|
);
|
|
3177
3695
|
};
|
|
3178
3696
|
},
|
|
3179
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
3697
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
3698
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
3699
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
3180
3700
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
3181
3701
|
node(nodeOptions = {}) {
|
|
3182
3702
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
3183
3703
|
return (req, res, next) => {
|
|
3704
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
3705
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3184
3708
|
const chunks = [];
|
|
3185
3709
|
let size = 0;
|
|
3186
3710
|
let overflow = false;
|
|
@@ -3198,38 +3722,41 @@ async function createProtection(options = {}) {
|
|
|
3198
3722
|
});
|
|
3199
3723
|
req.on("end", () => {
|
|
3200
3724
|
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
3201
|
-
|
|
3202
|
-
let shaped;
|
|
3203
|
-
let result;
|
|
3204
|
-
try {
|
|
3205
|
-
shaped = fromNodeRequest(req, rawBody);
|
|
3206
|
-
result = engine.evaluate(shaped);
|
|
3207
|
-
} catch (err) {
|
|
3208
|
-
notify(onError, err, "onError");
|
|
3209
|
-
return next();
|
|
3210
|
-
}
|
|
3211
|
-
decide(
|
|
3212
|
-
"request",
|
|
3213
|
-
result,
|
|
3214
|
-
() => {
|
|
3215
|
-
res.statusCode = 403;
|
|
3216
|
-
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3217
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3218
|
-
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3219
|
-
} else {
|
|
3220
|
-
res.setHeader("content-type", "application/json");
|
|
3221
|
-
res.end(JSON.stringify(blockBody(result)));
|
|
3222
|
-
}
|
|
3223
|
-
},
|
|
3224
|
-
() => {
|
|
3225
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
3226
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3227
|
-
next();
|
|
3228
|
-
},
|
|
3229
|
-
nodeRequestMeta(req)
|
|
3230
|
-
);
|
|
3725
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
3231
3726
|
});
|
|
3232
3727
|
};
|
|
3728
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
3729
|
+
let shaped;
|
|
3730
|
+
let result;
|
|
3731
|
+
try {
|
|
3732
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
3733
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
3734
|
+
result = engine.evaluate(shaped);
|
|
3735
|
+
} catch (err) {
|
|
3736
|
+
notify(onError, err, "onError");
|
|
3737
|
+
return next();
|
|
3738
|
+
}
|
|
3739
|
+
decide(
|
|
3740
|
+
"request",
|
|
3741
|
+
result,
|
|
3742
|
+
() => {
|
|
3743
|
+
res.statusCode = 403;
|
|
3744
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3745
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3746
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3747
|
+
} else {
|
|
3748
|
+
res.setHeader("content-type", "application/json");
|
|
3749
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
3750
|
+
}
|
|
3751
|
+
},
|
|
3752
|
+
() => {
|
|
3753
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
3754
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3755
|
+
next();
|
|
3756
|
+
},
|
|
3757
|
+
nodeRequestMeta(req)
|
|
3758
|
+
);
|
|
3759
|
+
}
|
|
3233
3760
|
}
|
|
3234
3761
|
};
|
|
3235
3762
|
if (options.egress) {
|
|
@@ -3266,24 +3793,22 @@ async function createProtection(options = {}) {
|
|
|
3266
3793
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3267
3794
|
mode = resolveMode(options, next);
|
|
3268
3795
|
applyBundle(next);
|
|
3796
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
3797
|
+
return next.source ?? { ok: true };
|
|
3269
3798
|
};
|
|
3270
3799
|
if (live) {
|
|
3271
3800
|
protection.refresh = () => runRefreshTick();
|
|
3272
3801
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
3273
3802
|
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
firewallLog.stop();
|
|
3284
|
-
detections?.stop();
|
|
3285
|
-
};
|
|
3286
|
-
}
|
|
3803
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
3804
|
+
protection.stop = () => {
|
|
3805
|
+
loop?.stop();
|
|
3806
|
+
firewallLog?.stop();
|
|
3807
|
+
detections?.stop();
|
|
3808
|
+
};
|
|
3809
|
+
protection.stopRefresh = protection.stop;
|
|
3810
|
+
protection.detectionReporting = detectionReporting;
|
|
3811
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
3287
3812
|
return protection;
|
|
3288
3813
|
}
|
|
3289
3814
|
function resolveMode(options, bundle) {
|
|
@@ -3294,22 +3819,35 @@ function resolveMode(options, bundle) {
|
|
|
3294
3819
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3295
3820
|
return "dry-run";
|
|
3296
3821
|
}
|
|
3822
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
3823
|
+
async function readCredentialField(options, field) {
|
|
3824
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3825
|
+
let readFileSync;
|
|
3826
|
+
let join;
|
|
3827
|
+
try {
|
|
3828
|
+
const [fs, pathMod] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3829
|
+
readFileSync = fs.readFileSync;
|
|
3830
|
+
join = pathMod.join;
|
|
3831
|
+
} catch {
|
|
3832
|
+
return void 0;
|
|
3833
|
+
}
|
|
3834
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3835
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
3836
|
+
try {
|
|
3837
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
3838
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3839
|
+
} catch {
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
return void 0;
|
|
3843
|
+
}
|
|
3297
3844
|
async function resolveApiKey(options) {
|
|
3298
3845
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3299
3846
|
if (typeof process !== "undefined") {
|
|
3300
3847
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3301
3848
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3302
3849
|
}
|
|
3303
|
-
|
|
3304
|
-
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3305
|
-
const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3306
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
3307
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3308
|
-
const key = JSON.parse(raw)?.apiKey;
|
|
3309
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
3310
|
-
} catch {
|
|
3311
|
-
}
|
|
3312
|
-
return void 0;
|
|
3850
|
+
return readCredentialField(options, "apiKey");
|
|
3313
3851
|
}
|
|
3314
3852
|
async function resolvePulseAuth(options) {
|
|
3315
3853
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -3317,15 +3855,8 @@ async function resolvePulseAuth(options) {
|
|
|
3317
3855
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3318
3856
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3319
3857
|
}
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3323
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
3324
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3325
|
-
const key = JSON.parse(raw)?.pulseAuth;
|
|
3326
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
3327
|
-
} catch {
|
|
3328
|
-
}
|
|
3858
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
3859
|
+
if (fromFile !== void 0) return fromFile;
|
|
3329
3860
|
return resolveApiKey(options);
|
|
3330
3861
|
}
|
|
3331
3862
|
function byPhase(rules, phase) {
|
|
@@ -3610,11 +4141,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
3610
4141
|
}
|
|
3611
4142
|
}
|
|
3612
4143
|
}
|
|
4144
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
3613
4145
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3614
4146
|
let out = String(cookie);
|
|
3615
4147
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3616
4148
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3617
|
-
|
|
4149
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
4150
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
3618
4151
|
return out;
|
|
3619
4152
|
}
|
|
3620
4153
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -3628,11 +4161,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3628
4161
|
} catch {
|
|
3629
4162
|
}
|
|
3630
4163
|
} else if (typeof value === "string") {
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
} catch {
|
|
3635
|
-
}
|
|
4164
|
+
try {
|
|
4165
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
4166
|
+
} catch {
|
|
3636
4167
|
}
|
|
3637
4168
|
} else if (Array.isArray(value)) {
|
|
3638
4169
|
try {
|