@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.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
pulseAuthHeader
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-LLKP5EJS.js";
|
|
4
4
|
|
|
5
5
|
// src/protect/safe-origin.js
|
|
6
6
|
function isSafeOrigin(value) {
|
|
@@ -131,6 +131,45 @@ function base64DecodeUtf8(value) {
|
|
|
131
131
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
132
132
|
return new TextDecoder().decode(bytes);
|
|
133
133
|
}
|
|
134
|
+
var NAMED_ENTITIES = {
|
|
135
|
+
lt: "<",
|
|
136
|
+
gt: ">",
|
|
137
|
+
amp: "&",
|
|
138
|
+
quot: '"',
|
|
139
|
+
apos: "'",
|
|
140
|
+
"#39": "'",
|
|
141
|
+
nbsp: "\xA0",
|
|
142
|
+
sol: "/",
|
|
143
|
+
bsol: "\\",
|
|
144
|
+
colon: ":",
|
|
145
|
+
lpar: "(",
|
|
146
|
+
rpar: ")",
|
|
147
|
+
equals: "=",
|
|
148
|
+
grave: "`",
|
|
149
|
+
Tab: " ",
|
|
150
|
+
NewLine: "\n",
|
|
151
|
+
semi: ";",
|
|
152
|
+
excl: "!",
|
|
153
|
+
num: "#",
|
|
154
|
+
dollar: "$",
|
|
155
|
+
percnt: "%",
|
|
156
|
+
ast: "*"
|
|
157
|
+
};
|
|
158
|
+
function decodeHtmlEntities(input) {
|
|
159
|
+
return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
160
|
+
if (body[0] === "#") {
|
|
161
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
162
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
163
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
|
|
164
|
+
try {
|
|
165
|
+
return String.fromCodePoint(code);
|
|
166
|
+
} catch {
|
|
167
|
+
return whole;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
134
173
|
var RequestResolver = class {
|
|
135
174
|
#req;
|
|
136
175
|
#cookies;
|
|
@@ -253,6 +292,8 @@ var RequestResolver = class {
|
|
|
253
292
|
} catch {
|
|
254
293
|
return value;
|
|
255
294
|
}
|
|
295
|
+
case "htmlentitydecode":
|
|
296
|
+
return decodeHtmlEntities(String(value));
|
|
256
297
|
case "intval":
|
|
257
298
|
return parseInt(String(value), 10) || 0;
|
|
258
299
|
case "getArrayValues":
|
|
@@ -2106,25 +2147,428 @@ function toEnvelope(value) {
|
|
|
2106
2147
|
return null;
|
|
2107
2148
|
}
|
|
2108
2149
|
|
|
2109
|
-
// src/protect/rules/
|
|
2110
|
-
var
|
|
2150
|
+
// src/protect/rules/contract.js
|
|
2151
|
+
var SOURCES = Object.freeze({
|
|
2152
|
+
raw: Object.freeze({ keyed: false }),
|
|
2153
|
+
all: Object.freeze({ keyed: false }),
|
|
2154
|
+
false: Object.freeze({ keyed: false }),
|
|
2155
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
2156
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2157
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2158
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2159
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2160
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
2161
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
2162
|
+
files: Object.freeze({
|
|
2163
|
+
keyed: true,
|
|
2164
|
+
keys: "any",
|
|
2165
|
+
wildcard: true,
|
|
2166
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
2167
|
+
}),
|
|
2168
|
+
server: Object.freeze({
|
|
2169
|
+
keyed: true,
|
|
2170
|
+
keys: Object.freeze([
|
|
2171
|
+
"REQUEST_URI",
|
|
2172
|
+
"REQUEST_METHOD",
|
|
2173
|
+
"HTTP_USER_AGENT",
|
|
2174
|
+
"HTTP_REFERER",
|
|
2175
|
+
"HTTP_HOST",
|
|
2176
|
+
"REMOTE_ADDR",
|
|
2177
|
+
"ip",
|
|
2178
|
+
"CONTENT_TYPE",
|
|
2179
|
+
"CONTENT_LENGTH"
|
|
2180
|
+
]),
|
|
2181
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
2182
|
+
}),
|
|
2183
|
+
response: Object.freeze({
|
|
2184
|
+
keyed: true,
|
|
2185
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
2186
|
+
key_prefixes: Object.freeze(["header."])
|
|
2187
|
+
}),
|
|
2188
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
2189
|
+
});
|
|
2190
|
+
var MATCH_TYPES = Object.freeze([
|
|
2191
|
+
"array_in_array",
|
|
2192
|
+
"array_key_value",
|
|
2193
|
+
"contains",
|
|
2194
|
+
"cors_reflected",
|
|
2195
|
+
"cross_origin",
|
|
2196
|
+
"ctype_alnum",
|
|
2197
|
+
"ctype_digit",
|
|
2198
|
+
"ctype_special",
|
|
2199
|
+
"equals",
|
|
2200
|
+
"equals_strict",
|
|
2201
|
+
"hostname",
|
|
2202
|
+
"in_array",
|
|
2203
|
+
"inline_js_xss",
|
|
2204
|
+
"inline_xss",
|
|
2205
|
+
"internal_host",
|
|
2206
|
+
"is_numeric",
|
|
2207
|
+
"isset",
|
|
2208
|
+
"less_than",
|
|
2209
|
+
"more_than",
|
|
2210
|
+
"not_contains",
|
|
2211
|
+
"not_in_array",
|
|
2212
|
+
"off_origin",
|
|
2213
|
+
"quotes",
|
|
2214
|
+
"regex",
|
|
2215
|
+
"stripos"
|
|
2216
|
+
]);
|
|
2217
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
2218
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
2219
|
+
"isset",
|
|
2220
|
+
"quotes",
|
|
2221
|
+
"is_numeric",
|
|
2222
|
+
"ctype_alnum",
|
|
2223
|
+
"ctype_digit",
|
|
2224
|
+
"ctype_special",
|
|
2225
|
+
"inline_xss",
|
|
2226
|
+
"inline_js_xss",
|
|
2227
|
+
"internal_host",
|
|
2228
|
+
"cross_origin",
|
|
2229
|
+
"off_origin",
|
|
2230
|
+
"cors_reflected"
|
|
2231
|
+
]);
|
|
2232
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
2233
|
+
"non-empty-string",
|
|
2234
|
+
"regex-literal",
|
|
2235
|
+
"string-or-string-list",
|
|
2236
|
+
"scalar",
|
|
2237
|
+
"string",
|
|
2238
|
+
"numeric",
|
|
2239
|
+
"scalar-or-non-empty-list",
|
|
2240
|
+
"non-empty-list",
|
|
2241
|
+
"object",
|
|
2242
|
+
"non-empty-object",
|
|
2243
|
+
"non-empty-string-list",
|
|
2244
|
+
"header-name-map",
|
|
2245
|
+
"header-name-list",
|
|
2246
|
+
"cookie-flags"
|
|
2247
|
+
]);
|
|
2248
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
2249
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
2250
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2251
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2252
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
2253
|
+
array_key_value: Object.freeze({
|
|
2254
|
+
required: Object.freeze(["key", "match"]),
|
|
2255
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
2256
|
+
}),
|
|
2257
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
2258
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2259
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2260
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2261
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
2262
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
2263
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
2264
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
2265
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2266
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2267
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2268
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
2269
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
2270
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
2271
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
2272
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
2273
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
2274
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
2275
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
2276
|
+
});
|
|
2277
|
+
function operandShapeProblem(shape, value) {
|
|
2278
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
2279
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
2280
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
2281
|
+
switch (shape) {
|
|
2282
|
+
case "non-empty-string":
|
|
2283
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
2284
|
+
case "regex-literal":
|
|
2285
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
2286
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
2287
|
+
case "string-or-string-list":
|
|
2288
|
+
if (isFilledString(value)) return null;
|
|
2289
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
2290
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
2291
|
+
case "scalar":
|
|
2292
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
2293
|
+
case "string":
|
|
2294
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
2295
|
+
case "numeric":
|
|
2296
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
2297
|
+
case "scalar-or-non-empty-list":
|
|
2298
|
+
if (isScalar(value)) return null;
|
|
2299
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
2300
|
+
return "must be a scalar, or a non-empty list of them";
|
|
2301
|
+
case "non-empty-list":
|
|
2302
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
2303
|
+
case "header-name-map": {
|
|
2304
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2305
|
+
const names = Object.keys(value);
|
|
2306
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
2307
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
2308
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2309
|
+
}
|
|
2310
|
+
case "header-name-list": {
|
|
2311
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
2312
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
2313
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2314
|
+
}
|
|
2315
|
+
case "cookie-flags": {
|
|
2316
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2317
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
2318
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
2319
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
2320
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
2321
|
+
return `flag "${flag}" must be true or false`;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
const sameSite = value.sameSite;
|
|
2325
|
+
const sameSiteOff = sameSite === false;
|
|
2326
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
2327
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
2328
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
2332
|
+
return "turns off every flag, so it would harden nothing";
|
|
2333
|
+
}
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
case "object":
|
|
2337
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
2338
|
+
case "non-empty-object":
|
|
2339
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
2340
|
+
case "non-empty-string-list":
|
|
2341
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
2342
|
+
default:
|
|
2343
|
+
return null;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
var MUTATIONS = Object.freeze([
|
|
2347
|
+
"base64_decode",
|
|
2348
|
+
"getArrayValues",
|
|
2349
|
+
"htmlentitydecode",
|
|
2350
|
+
"intval",
|
|
2351
|
+
"json_decode",
|
|
2352
|
+
"json_encode",
|
|
2353
|
+
"urldecode"
|
|
2354
|
+
]);
|
|
2355
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
2356
|
+
var ACTIONS = Object.freeze([
|
|
2357
|
+
"block",
|
|
2358
|
+
"redact",
|
|
2359
|
+
"encode",
|
|
2360
|
+
"set-header",
|
|
2361
|
+
"remove-header",
|
|
2362
|
+
"harden-cookie"
|
|
2363
|
+
]);
|
|
2364
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
2365
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
2366
|
+
"id",
|
|
2367
|
+
"rule_id",
|
|
2368
|
+
"title",
|
|
2369
|
+
"category",
|
|
2370
|
+
"phase",
|
|
2371
|
+
"action",
|
|
2372
|
+
"rule_v2",
|
|
2373
|
+
"when",
|
|
2374
|
+
"message",
|
|
2375
|
+
"enforcement",
|
|
2376
|
+
"source_revision",
|
|
2377
|
+
"prefilter",
|
|
2378
|
+
"max_bytes",
|
|
2379
|
+
"bypass_limit",
|
|
2380
|
+
"set_headers",
|
|
2381
|
+
"remove_headers",
|
|
2382
|
+
"cookie_flags",
|
|
2383
|
+
"ensure"
|
|
2384
|
+
]);
|
|
2385
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
2386
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
2387
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2388
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2389
|
+
"set-header": Object.freeze({
|
|
2390
|
+
required: Object.freeze(["set_headers"]),
|
|
2391
|
+
defaulted: Object.freeze(["ensure"]),
|
|
2392
|
+
phases: Object.freeze(["response"]),
|
|
2393
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
2394
|
+
}),
|
|
2395
|
+
"remove-header": Object.freeze({
|
|
2396
|
+
required: Object.freeze(["remove_headers"]),
|
|
2397
|
+
defaulted: Object.freeze([]),
|
|
2398
|
+
phases: Object.freeze(["response"]),
|
|
2399
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
2400
|
+
}),
|
|
2401
|
+
"harden-cookie": Object.freeze({
|
|
2402
|
+
required: Object.freeze([]),
|
|
2403
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
2404
|
+
phases: Object.freeze(["response"]),
|
|
2405
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
2406
|
+
})
|
|
2407
|
+
});
|
|
2408
|
+
var LIMITS = Object.freeze({
|
|
2111
2409
|
maxRules: 5e3,
|
|
2112
2410
|
maxWhitelists: 2e3,
|
|
2113
2411
|
maxConditionsPerRule: 250,
|
|
2114
2412
|
maxNestingDepth: 12,
|
|
2115
2413
|
maxRegexLength: 1e3,
|
|
2116
2414
|
maxValueLength: 8192
|
|
2117
|
-
};
|
|
2118
|
-
var
|
|
2119
|
-
|
|
2415
|
+
});
|
|
2416
|
+
var GROUP_PARAMETER = "rules";
|
|
2417
|
+
function isGroup(condition) {
|
|
2418
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
2419
|
+
}
|
|
2420
|
+
function conditionShapeProblem(condition) {
|
|
2421
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
2422
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
2423
|
+
const nested = Array.isArray(condition.rules);
|
|
2424
|
+
if (named && !nested) {
|
|
2425
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
2426
|
+
}
|
|
2427
|
+
if (nested && !named) {
|
|
2428
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2429
|
+
}
|
|
2430
|
+
if (named && nested) {
|
|
2431
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2432
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
2433
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
2434
|
+
}
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
function parameterProblem(parameter) {
|
|
2438
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
2439
|
+
if (Array.isArray(parameter)) {
|
|
2440
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
2441
|
+
for (const member of parameter) {
|
|
2442
|
+
const problem = parameterProblem(member);
|
|
2443
|
+
if (problem !== null) return problem;
|
|
2444
|
+
}
|
|
2445
|
+
return null;
|
|
2446
|
+
}
|
|
2447
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
2448
|
+
if (parameter === GROUP_PARAMETER) {
|
|
2449
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
2450
|
+
}
|
|
2451
|
+
const dot = parameter.indexOf(".");
|
|
2452
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
2453
|
+
const source = SOURCES[name];
|
|
2454
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
2455
|
+
if (dot === -1) {
|
|
2456
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
2457
|
+
}
|
|
2458
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
2459
|
+
const key = parameter.slice(dot + 1);
|
|
2460
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
2461
|
+
return keyProblem(name, source, key);
|
|
2462
|
+
}
|
|
2463
|
+
function keyProblem(name, source, key) {
|
|
2464
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
2465
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
2466
|
+
}
|
|
2467
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
2468
|
+
if (source.keys === "any") return null;
|
|
2469
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
2470
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
2471
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
2472
|
+
}
|
|
2473
|
+
const accepted = [
|
|
2474
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
2475
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2476
|
+
].join(", ");
|
|
2477
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2478
|
+
}
|
|
2479
|
+
function matchProblem(match, parameter) {
|
|
2480
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
2481
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
2482
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
2483
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
2484
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
2485
|
+
return `match type "${match.type}" needs a parameter`;
|
|
2486
|
+
}
|
|
2487
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
2488
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
2489
|
+
}
|
|
2490
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
2491
|
+
if (operands) {
|
|
2492
|
+
for (const required of operands.required) {
|
|
2493
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
2494
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
2498
|
+
if (match[operand] === void 0) continue;
|
|
2499
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
2500
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
2501
|
+
}
|
|
2502
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
2503
|
+
return `match type "${match.type}" needs a value`;
|
|
2504
|
+
}
|
|
2505
|
+
return null;
|
|
2506
|
+
}
|
|
2507
|
+
function mutationsProblem(mutations) {
|
|
2508
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
2509
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
2510
|
+
for (const mutation of mutations) {
|
|
2511
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
2512
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
return null;
|
|
2516
|
+
}
|
|
2517
|
+
function whenProblem(when) {
|
|
2518
|
+
if (when === void 0 || when === null) return null;
|
|
2519
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
2520
|
+
const keys = Object.keys(when);
|
|
2521
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
2522
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
2523
|
+
if (unknown.length > 0) {
|
|
2524
|
+
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`;
|
|
2525
|
+
}
|
|
2526
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
2527
|
+
return "when.path must be a non-empty string";
|
|
2528
|
+
}
|
|
2529
|
+
if ("method" in when) {
|
|
2530
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
2531
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
2532
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
2533
|
+
}
|
|
2534
|
+
return null;
|
|
2535
|
+
}
|
|
2536
|
+
function isNonEmptyString(value) {
|
|
2537
|
+
return typeof value === "string" && value.trim() !== "";
|
|
2538
|
+
}
|
|
2539
|
+
function actionProblem(action, rule) {
|
|
2540
|
+
if (action === void 0 || action === null) return null;
|
|
2541
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2542
|
+
const spec = ACTION_PROPERTIES[action];
|
|
2543
|
+
for (const required of spec?.required ?? []) {
|
|
2544
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2545
|
+
return `action "${action}" needs "${required}"`;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2549
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2550
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2551
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2552
|
+
}
|
|
2553
|
+
const phase = rule?.phase ?? "request";
|
|
2554
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2555
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2556
|
+
}
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// src/protect/rules/validate.js
|
|
2561
|
+
var LIMITS2 = LIMITS;
|
|
2562
|
+
var PHASES2 = new Set(PHASES);
|
|
2563
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
2120
2564
|
function validateBundle(bundle, opts = {}) {
|
|
2121
2565
|
const rejected = [];
|
|
2122
2566
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2123
2567
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2124
2568
|
const firewall = [];
|
|
2125
2569
|
for (const rule of inFirewall) {
|
|
2126
|
-
if (firewall.length >=
|
|
2127
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
2570
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
2571
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2128
2572
|
continue;
|
|
2129
2573
|
}
|
|
2130
2574
|
const reason = ruleProblem(rule);
|
|
@@ -2133,8 +2577,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
2133
2577
|
}
|
|
2134
2578
|
const whitelists = [];
|
|
2135
2579
|
for (const wl of inWhitelists) {
|
|
2136
|
-
if (whitelists.length >=
|
|
2137
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
2580
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
2581
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
2138
2582
|
continue;
|
|
2139
2583
|
}
|
|
2140
2584
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -2156,8 +2600,11 @@ function idOf(rule) {
|
|
|
2156
2600
|
}
|
|
2157
2601
|
function ruleProblem(rule) {
|
|
2158
2602
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
2159
|
-
if (rule.phase !== void 0 && !
|
|
2160
|
-
|
|
2603
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2604
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
2605
|
+
if (actionReason) return actionReason;
|
|
2606
|
+
const scopeReason = whenProblem(rule.when);
|
|
2607
|
+
if (scopeReason) return scopeReason;
|
|
2161
2608
|
const capOverride = rule.max_bytes;
|
|
2162
2609
|
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
2163
2610
|
return conditionsProblem(rule.rule_v2);
|
|
@@ -2165,28 +2612,33 @@ function ruleProblem(rule) {
|
|
|
2165
2612
|
function conditionsProblem(conditions, depth = 0) {
|
|
2166
2613
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2167
2614
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2168
|
-
if (depth >
|
|
2169
|
-
if (conditions.length >
|
|
2170
|
-
return `more than ${
|
|
2615
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
2616
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
2617
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
2171
2618
|
}
|
|
2172
2619
|
for (const c of conditions) {
|
|
2173
|
-
|
|
2174
|
-
if (
|
|
2620
|
+
const shapeReason = conditionShapeProblem(c);
|
|
2621
|
+
if (shapeReason) return shapeReason;
|
|
2622
|
+
if (isGroup(c)) {
|
|
2175
2623
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2176
2624
|
if (nested) return nested;
|
|
2177
2625
|
continue;
|
|
2178
2626
|
}
|
|
2627
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
2628
|
+
if (parameterReason) return parameterReason;
|
|
2629
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
2630
|
+
if (mutationReason) return mutationReason;
|
|
2631
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
2632
|
+
if (matchReason) return matchReason;
|
|
2179
2633
|
const m = c.match;
|
|
2180
|
-
if (!m || typeof m !== "object") return "condition has no match object";
|
|
2181
|
-
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
2182
2634
|
if (m.type === "regex") {
|
|
2183
2635
|
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2184
|
-
if (m.value.length >
|
|
2185
|
-
} else if (typeof m.value === "string" && m.value.length >
|
|
2186
|
-
return `match.value longer than ${
|
|
2636
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2637
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2638
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2187
2639
|
}
|
|
2188
2640
|
if (m.match) {
|
|
2189
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2641
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2190
2642
|
if (nested) return nested;
|
|
2191
2643
|
}
|
|
2192
2644
|
}
|
|
@@ -2214,64 +2666,67 @@ function reportRejections(rejected, options, label) {
|
|
|
2214
2666
|
`${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" : ""}`
|
|
2215
2667
|
), "onError");
|
|
2216
2668
|
}
|
|
2669
|
+
function fromSource(bundle, reason) {
|
|
2670
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
2671
|
+
}
|
|
2217
2672
|
async function resolveRules(options, store, ctx = {}) {
|
|
2218
2673
|
const timeoutMs = ctx.timeoutMs;
|
|
2219
2674
|
if (options.siteUuid) {
|
|
2220
2675
|
const prior = await store.read();
|
|
2221
2676
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2222
2677
|
const res = await client.getRules();
|
|
2223
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2678
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2224
2679
|
if (res.success && !res.notModified) {
|
|
2225
2680
|
const rejected = liveUpdateRejections(res, options);
|
|
2226
2681
|
if (rejected.length > 0) {
|
|
2227
2682
|
reportRejections(rejected, options, "rule update rejected");
|
|
2228
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2229
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2230
|
-
return emptyBundle();
|
|
2683
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2684
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2685
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2231
2686
|
}
|
|
2232
2687
|
const bundle = normalizeBundle(res, options);
|
|
2233
2688
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2234
|
-
return bundle;
|
|
2689
|
+
return fromSource(bundle);
|
|
2235
2690
|
}
|
|
2236
2691
|
if (prior?.bundle) {
|
|
2237
2692
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2238
|
-
return normalizeBundle(prior.bundle, options);
|
|
2693
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2239
2694
|
}
|
|
2240
2695
|
if (options.rules) {
|
|
2241
2696
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2242
|
-
return normalizeBundle(options.rules, options);
|
|
2697
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
2243
2698
|
}
|
|
2244
2699
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2245
|
-
return emptyBundle();
|
|
2700
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2246
2701
|
}
|
|
2247
2702
|
if (options.token) {
|
|
2248
2703
|
const prior = await store.read();
|
|
2249
2704
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2250
2705
|
const res = await client.getRules();
|
|
2251
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2706
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2252
2707
|
if (res.success && !res.notModified) {
|
|
2253
2708
|
const rejected = liveUpdateRejections(res, options);
|
|
2254
2709
|
if (rejected.length > 0) {
|
|
2255
2710
|
reportRejections(rejected, options, "rule update rejected");
|
|
2256
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2257
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2258
|
-
return emptyBundle();
|
|
2711
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2712
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2713
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2259
2714
|
}
|
|
2260
2715
|
const bundle = normalizeBundle(res, options);
|
|
2261
2716
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2262
|
-
return bundle;
|
|
2717
|
+
return fromSource(bundle);
|
|
2263
2718
|
}
|
|
2264
2719
|
if (prior?.bundle) {
|
|
2265
2720
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2266
|
-
return normalizeBundle(prior.bundle, options);
|
|
2721
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2267
2722
|
}
|
|
2268
2723
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2269
|
-
return emptyBundle();
|
|
2724
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2270
2725
|
}
|
|
2271
2726
|
if (options.rules) {
|
|
2272
|
-
return normalizeBundle(options.rules, options);
|
|
2727
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
2273
2728
|
}
|
|
2274
|
-
return emptyBundle();
|
|
2729
|
+
return fromSource(emptyBundle());
|
|
2275
2730
|
}
|
|
2276
2731
|
function normalizeBundle(b, options = {}) {
|
|
2277
2732
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -2324,8 +2779,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2324
2779
|
const run = async () => {
|
|
2325
2780
|
if (stopped) return;
|
|
2326
2781
|
try {
|
|
2327
|
-
await tick();
|
|
2328
|
-
|
|
2782
|
+
const status = await tick();
|
|
2783
|
+
if (status && status.ok === false) failures++;
|
|
2784
|
+
else failures = 0;
|
|
2329
2785
|
} catch (err) {
|
|
2330
2786
|
failures++;
|
|
2331
2787
|
notify(onError, err, "onError");
|
|
@@ -2352,7 +2808,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2352
2808
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
2353
2809
|
let refreshed = true;
|
|
2354
2810
|
try {
|
|
2355
|
-
await tick();
|
|
2811
|
+
const status = await tick();
|
|
2812
|
+
if (status && status.ok === false) refreshed = false;
|
|
2356
2813
|
} catch {
|
|
2357
2814
|
refreshed = false;
|
|
2358
2815
|
}
|
|
@@ -2380,6 +2837,12 @@ function ruleParameters(rule) {
|
|
|
2380
2837
|
walk(rule?.rule_v2);
|
|
2381
2838
|
return [...out];
|
|
2382
2839
|
}
|
|
2840
|
+
function revisionOf(rule) {
|
|
2841
|
+
const revision = rule?.source_revision;
|
|
2842
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
2843
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
2844
|
+
return null;
|
|
2845
|
+
}
|
|
2383
2846
|
function routeOf(path) {
|
|
2384
2847
|
if (typeof path !== "string" || path === "") return null;
|
|
2385
2848
|
const cut = path.search(/[?#]/);
|
|
@@ -2388,16 +2851,30 @@ function routeOf(path) {
|
|
|
2388
2851
|
function createDetectionReporter(opts) {
|
|
2389
2852
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2390
2853
|
if (!siteUuid) {
|
|
2391
|
-
return {
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2854
|
+
return {
|
|
2855
|
+
record() {
|
|
2856
|
+
},
|
|
2857
|
+
flush() {
|
|
2858
|
+
},
|
|
2859
|
+
stop() {
|
|
2860
|
+
},
|
|
2861
|
+
setRulesEtag() {
|
|
2862
|
+
},
|
|
2863
|
+
dropped: () => 0,
|
|
2864
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
2865
|
+
};
|
|
2395
2866
|
}
|
|
2396
2867
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2397
2868
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2398
2869
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2399
2870
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2400
2871
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
2872
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
2873
|
+
let sent = 0;
|
|
2874
|
+
let delivered = 0;
|
|
2875
|
+
let failed = 0;
|
|
2876
|
+
let droppedTotal = 0;
|
|
2877
|
+
let lastDeliveredAt = null;
|
|
2401
2878
|
let queue = [];
|
|
2402
2879
|
let timer = null;
|
|
2403
2880
|
let stopped = false;
|
|
@@ -2411,6 +2888,8 @@ function createDetectionReporter(opts) {
|
|
|
2411
2888
|
const batch = queue.splice(0, MAX_BATCH);
|
|
2412
2889
|
const droppedWith = dropped;
|
|
2413
2890
|
dropped = 0;
|
|
2891
|
+
droppedTotal += droppedWith;
|
|
2892
|
+
sent += batch.length;
|
|
2414
2893
|
void (async () => {
|
|
2415
2894
|
try {
|
|
2416
2895
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -2419,15 +2898,22 @@ function createDetectionReporter(opts) {
|
|
|
2419
2898
|
"Content-Type": "application/json",
|
|
2420
2899
|
Accept: "application/json",
|
|
2421
2900
|
"User-Agent": "@patchstack/connect",
|
|
2422
|
-
// Same credential path as the rules fetch
|
|
2423
|
-
//
|
|
2901
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
2902
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
2903
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
2904
|
+
// posting into a 401.
|
|
2424
2905
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2425
2906
|
},
|
|
2426
2907
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2427
2908
|
});
|
|
2428
|
-
if (res &&
|
|
2429
|
-
|
|
2909
|
+
if (res && res.ok) {
|
|
2910
|
+
delivered += batch.length;
|
|
2911
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2912
|
+
} else {
|
|
2913
|
+
failed += batch.length;
|
|
2914
|
+
}
|
|
2430
2915
|
} catch {
|
|
2916
|
+
failed += batch.length;
|
|
2431
2917
|
}
|
|
2432
2918
|
})();
|
|
2433
2919
|
};
|
|
@@ -2456,7 +2942,12 @@ function createDetectionReporter(opts) {
|
|
|
2456
2942
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2457
2943
|
// saw traffic it would have stopped.
|
|
2458
2944
|
enforced: detection.mode === "block",
|
|
2459
|
-
rules_etag:
|
|
2945
|
+
rules_etag: rulesEtag,
|
|
2946
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
2947
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
2948
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
2949
|
+
// bundle carried none.
|
|
2950
|
+
rule_revision: revisionOf(detection.rule),
|
|
2460
2951
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2461
2952
|
});
|
|
2462
2953
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -2470,7 +2961,24 @@ function createDetectionReporter(opts) {
|
|
|
2470
2961
|
stopped = true;
|
|
2471
2962
|
flush();
|
|
2472
2963
|
},
|
|
2473
|
-
|
|
2964
|
+
/**
|
|
2965
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
2966
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
2967
|
+
*
|
|
2968
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
2969
|
+
* the only moment the two are known to agree.
|
|
2970
|
+
*
|
|
2971
|
+
* @param {string | null | undefined} next
|
|
2972
|
+
*/
|
|
2973
|
+
setRulesEtag(next) {
|
|
2974
|
+
rulesEtag = next ?? null;
|
|
2975
|
+
},
|
|
2976
|
+
dropped: () => dropped,
|
|
2977
|
+
/**
|
|
2978
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
2979
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
2980
|
+
*/
|
|
2981
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
2474
2982
|
};
|
|
2475
2983
|
}
|
|
2476
2984
|
|
|
@@ -2735,22 +3243,32 @@ async function createProtection(options = {}) {
|
|
|
2735
3243
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2736
3244
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2737
3245
|
if (options.siteUuid && !pulseAuth) {
|
|
2738
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
3246
|
+
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.";
|
|
2739
3247
|
notify(onError, new Error(message), "onError");
|
|
2740
3248
|
console.warn(message);
|
|
2741
3249
|
}
|
|
2742
3250
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
3251
|
+
let detectionReporting = "off";
|
|
2743
3252
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
3253
|
+
if (!pulseAuth) {
|
|
3254
|
+
detectionReporting = "unavailable-no-credential";
|
|
3255
|
+
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.";
|
|
3256
|
+
notify(onError, new Error(message), "onError");
|
|
3257
|
+
console.warn(message);
|
|
3258
|
+
} else {
|
|
3259
|
+
detectionReporting = "on";
|
|
3260
|
+
detections = createDetectionReporter({
|
|
3261
|
+
siteUuid: options.siteUuid,
|
|
3262
|
+
baseUrl: options.pulseRulesUrl,
|
|
3263
|
+
pulseAuth,
|
|
3264
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
3265
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
3266
|
+
// see the refresh tick below.
|
|
3267
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
3268
|
+
fetchImpl: options.fetchImpl,
|
|
3269
|
+
flushMs: options.detectionFlushMs
|
|
3270
|
+
});
|
|
3271
|
+
}
|
|
2754
3272
|
}
|
|
2755
3273
|
let mode = resolveMode(options, bundle);
|
|
2756
3274
|
let requestRules;
|
|
@@ -3084,11 +3602,17 @@ async function createProtection(options = {}) {
|
|
|
3084
3602
|
);
|
|
3085
3603
|
};
|
|
3086
3604
|
},
|
|
3087
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
3605
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
3606
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
3607
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
3088
3608
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
3089
3609
|
node(nodeOptions = {}) {
|
|
3090
3610
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
3091
3611
|
return (req, res, next) => {
|
|
3612
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
3613
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3092
3616
|
const chunks = [];
|
|
3093
3617
|
let size = 0;
|
|
3094
3618
|
let overflow = false;
|
|
@@ -3106,38 +3630,41 @@ async function createProtection(options = {}) {
|
|
|
3106
3630
|
});
|
|
3107
3631
|
req.on("end", () => {
|
|
3108
3632
|
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
3109
|
-
|
|
3110
|
-
let shaped;
|
|
3111
|
-
let result;
|
|
3112
|
-
try {
|
|
3113
|
-
shaped = fromNodeRequest(req, rawBody);
|
|
3114
|
-
result = engine.evaluate(shaped);
|
|
3115
|
-
} catch (err) {
|
|
3116
|
-
notify(onError, err, "onError");
|
|
3117
|
-
return next();
|
|
3118
|
-
}
|
|
3119
|
-
decide(
|
|
3120
|
-
"request",
|
|
3121
|
-
result,
|
|
3122
|
-
() => {
|
|
3123
|
-
res.statusCode = 403;
|
|
3124
|
-
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3125
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3126
|
-
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3127
|
-
} else {
|
|
3128
|
-
res.setHeader("content-type", "application/json");
|
|
3129
|
-
res.end(JSON.stringify(blockBody(result)));
|
|
3130
|
-
}
|
|
3131
|
-
},
|
|
3132
|
-
() => {
|
|
3133
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
3134
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3135
|
-
next();
|
|
3136
|
-
},
|
|
3137
|
-
nodeRequestMeta(req)
|
|
3138
|
-
);
|
|
3633
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
3139
3634
|
});
|
|
3140
3635
|
};
|
|
3636
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
3637
|
+
let shaped;
|
|
3638
|
+
let result;
|
|
3639
|
+
try {
|
|
3640
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
3641
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
3642
|
+
result = engine.evaluate(shaped);
|
|
3643
|
+
} catch (err) {
|
|
3644
|
+
notify(onError, err, "onError");
|
|
3645
|
+
return next();
|
|
3646
|
+
}
|
|
3647
|
+
decide(
|
|
3648
|
+
"request",
|
|
3649
|
+
result,
|
|
3650
|
+
() => {
|
|
3651
|
+
res.statusCode = 403;
|
|
3652
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3653
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3654
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3655
|
+
} else {
|
|
3656
|
+
res.setHeader("content-type", "application/json");
|
|
3657
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
3658
|
+
}
|
|
3659
|
+
},
|
|
3660
|
+
() => {
|
|
3661
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
3662
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3663
|
+
next();
|
|
3664
|
+
},
|
|
3665
|
+
nodeRequestMeta(req)
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3141
3668
|
}
|
|
3142
3669
|
};
|
|
3143
3670
|
if (options.egress) {
|
|
@@ -3158,7 +3685,7 @@ async function createProtection(options = {}) {
|
|
|
3158
3685
|
let reporter = null;
|
|
3159
3686
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
3160
3687
|
try {
|
|
3161
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
3688
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-ZSP76JWQ.js"));
|
|
3162
3689
|
} catch (err) {
|
|
3163
3690
|
notify(onError, err, "onError");
|
|
3164
3691
|
}
|
|
@@ -3174,24 +3701,22 @@ async function createProtection(options = {}) {
|
|
|
3174
3701
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3175
3702
|
mode = resolveMode(options, next);
|
|
3176
3703
|
applyBundle(next);
|
|
3704
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
3705
|
+
return next.source ?? { ok: true };
|
|
3177
3706
|
};
|
|
3178
3707
|
if (live) {
|
|
3179
3708
|
protection.refresh = () => runRefreshTick();
|
|
3180
3709
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
3181
3710
|
}
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
firewallLog.stop();
|
|
3192
|
-
detections?.stop();
|
|
3193
|
-
};
|
|
3194
|
-
}
|
|
3711
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
3712
|
+
protection.stop = () => {
|
|
3713
|
+
loop?.stop();
|
|
3714
|
+
firewallLog?.stop();
|
|
3715
|
+
detections?.stop();
|
|
3716
|
+
};
|
|
3717
|
+
protection.stopRefresh = protection.stop;
|
|
3718
|
+
protection.detectionReporting = detectionReporting;
|
|
3719
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
3195
3720
|
return protection;
|
|
3196
3721
|
}
|
|
3197
3722
|
function resolveMode(options, bundle) {
|
|
@@ -3202,22 +3727,35 @@ function resolveMode(options, bundle) {
|
|
|
3202
3727
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3203
3728
|
return "dry-run";
|
|
3204
3729
|
}
|
|
3730
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
3731
|
+
async function readCredentialField(options, field) {
|
|
3732
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3733
|
+
let readFileSync;
|
|
3734
|
+
let join;
|
|
3735
|
+
try {
|
|
3736
|
+
const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
|
|
3737
|
+
readFileSync = fs.readFileSync;
|
|
3738
|
+
join = pathMod.join;
|
|
3739
|
+
} catch {
|
|
3740
|
+
return void 0;
|
|
3741
|
+
}
|
|
3742
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3743
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
3744
|
+
try {
|
|
3745
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
3746
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3747
|
+
} catch {
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
return void 0;
|
|
3751
|
+
}
|
|
3205
3752
|
async function resolveApiKey(options) {
|
|
3206
3753
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3207
3754
|
if (typeof process !== "undefined") {
|
|
3208
3755
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3209
3756
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3210
3757
|
}
|
|
3211
|
-
|
|
3212
|
-
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3213
|
-
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
3214
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
3215
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3216
|
-
const key = JSON.parse(raw)?.apiKey;
|
|
3217
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
3218
|
-
} catch {
|
|
3219
|
-
}
|
|
3220
|
-
return void 0;
|
|
3758
|
+
return readCredentialField(options, "apiKey");
|
|
3221
3759
|
}
|
|
3222
3760
|
async function resolvePulseAuth(options) {
|
|
3223
3761
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -3225,15 +3763,8 @@ async function resolvePulseAuth(options) {
|
|
|
3225
3763
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3226
3764
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3227
3765
|
}
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
3231
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
3232
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3233
|
-
const key = JSON.parse(raw)?.pulseAuth;
|
|
3234
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
3235
|
-
} catch {
|
|
3236
|
-
}
|
|
3766
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
3767
|
+
if (fromFile !== void 0) return fromFile;
|
|
3237
3768
|
return resolveApiKey(options);
|
|
3238
3769
|
}
|
|
3239
3770
|
function byPhase(rules, phase) {
|
|
@@ -3518,11 +4049,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
3518
4049
|
}
|
|
3519
4050
|
}
|
|
3520
4051
|
}
|
|
4052
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
3521
4053
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3522
4054
|
let out = String(cookie);
|
|
3523
4055
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3524
4056
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3525
|
-
|
|
4057
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
4058
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
3526
4059
|
return out;
|
|
3527
4060
|
}
|
|
3528
4061
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -3536,11 +4069,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3536
4069
|
} catch {
|
|
3537
4070
|
}
|
|
3538
4071
|
} else if (typeof value === "string") {
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
} catch {
|
|
3543
|
-
}
|
|
4072
|
+
try {
|
|
4073
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
4074
|
+
} catch {
|
|
3544
4075
|
}
|
|
3545
4076
|
} else if (Array.isArray(value)) {
|
|
3546
4077
|
try {
|