@patchstack/connect 0.3.29 → 0.3.31
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 +42 -23
- package/README.md +40 -11
- package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
- package/dist/chunk-LLKP5EJS.js.map +1 -0
- package/dist/cli.js +1265 -287
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +456 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +76 -10
- package/dist/index.d.ts +76 -10
- package/dist/index.js +449 -104
- 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 +1204 -252
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +39 -11
- package/dist/protect.edge.js +792 -143
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +794 -145
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-KNZQBC4V.js} +387 -100
- package/dist/refresh-manifest-KNZQBC4V.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":
|
|
@@ -949,6 +990,30 @@ function expandIPv6(host) {
|
|
|
949
990
|
}
|
|
950
991
|
return out;
|
|
951
992
|
}
|
|
993
|
+
var JWT_CANDIDATE = /(?<![A-Za-z0-9_-])(?<![A-Za-z0-9_-]\.)eyJ[A-Za-z0-9_-]{2,2048}\.[A-Za-z0-9_-]{2,8192}\.[A-Za-z0-9_-]{0,2048}(?![A-Za-z0-9_-])(?!\.[A-Za-z0-9_-])/g;
|
|
994
|
+
function base64UrlToBase64(segment) {
|
|
995
|
+
const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
|
|
996
|
+
return padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
997
|
+
}
|
|
998
|
+
function jwtClaimSpans(text, claim, value) {
|
|
999
|
+
const found = /* @__PURE__ */ new Set();
|
|
1000
|
+
if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
|
|
1001
|
+
const wanted = String(value);
|
|
1002
|
+
for (const [token] of text.matchAll(JWT_CANDIDATE)) {
|
|
1003
|
+
const parts = token.split(".");
|
|
1004
|
+
let payload;
|
|
1005
|
+
try {
|
|
1006
|
+
payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
|
|
1007
|
+
} catch {
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
1011
|
+
if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
|
|
1012
|
+
const actual = payload[claim];
|
|
1013
|
+
if (typeof actual === "string" && actual === wanted) found.add(token);
|
|
1014
|
+
}
|
|
1015
|
+
return [...found];
|
|
1016
|
+
}
|
|
952
1017
|
var warnedScopes = /* @__PURE__ */ new Set();
|
|
953
1018
|
function warnUnrecognisedScope(when) {
|
|
954
1019
|
const key = Object.keys(when).sort().join(",");
|
|
@@ -977,16 +1042,30 @@ function ruleAppliesTo(when, resolver) {
|
|
|
977
1042
|
return true;
|
|
978
1043
|
}
|
|
979
1044
|
}
|
|
1045
|
+
function foldPathForScope(value) {
|
|
1046
|
+
const out = String(value).toLowerCase();
|
|
1047
|
+
return out.endsWith("/") ? out.slice(0, -1) : out;
|
|
1048
|
+
}
|
|
1049
|
+
var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
|
|
1050
|
+
var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
1051
|
+
function isRegexForm(pattern) {
|
|
1052
|
+
const parts = REGEX_FORM.exec(pattern);
|
|
1053
|
+
return parts !== null && REGEX_SYNTAX.test(parts[1]);
|
|
1054
|
+
}
|
|
980
1055
|
function pathMatches(pattern, path) {
|
|
981
|
-
if (
|
|
1056
|
+
if (isRegexForm(pattern)) {
|
|
982
1057
|
const re = safeRegExp(pattern);
|
|
983
1058
|
return re ? re.test(path) : false;
|
|
984
1059
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1060
|
+
const target = foldPathForScope(path);
|
|
1061
|
+
const folded = foldPathForScope(pattern);
|
|
1062
|
+
if (folded.includes("*")) {
|
|
1063
|
+
const trailing = folded.endsWith("/*");
|
|
1064
|
+
const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
|
|
1065
|
+
const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
|
|
1066
|
+
return re ? re.test(target) : false;
|
|
988
1067
|
}
|
|
989
|
-
return
|
|
1068
|
+
return target === folded;
|
|
990
1069
|
}
|
|
991
1070
|
function normalizeDefaultPort(host) {
|
|
992
1071
|
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
@@ -1139,6 +1218,8 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1139
1218
|
const stripped = strValue.replace(/[ _\-,]/g, "");
|
|
1140
1219
|
return ctypeResult(strValue, /^[\w$-]*$/.test(stripped), matchVal);
|
|
1141
1220
|
}
|
|
1221
|
+
case "jwt_claim_equals":
|
|
1222
|
+
return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
|
|
1142
1223
|
case "array_key_value":
|
|
1143
1224
|
return arrayKeyValue(value, matchObj);
|
|
1144
1225
|
// file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
|
|
@@ -1967,13 +2048,43 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1967
2048
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
|
|
1968
2049
|
},
|
|
1969
2050
|
{
|
|
1970
|
-
id: "resp-
|
|
1971
|
-
title: "
|
|
2051
|
+
id: "resp-supabase-secret-key",
|
|
2052
|
+
title: "Supabase secret key in response body",
|
|
1972
2053
|
phase: "response",
|
|
1973
2054
|
category: "secret-exposure",
|
|
1974
2055
|
action: "redact",
|
|
2056
|
+
// Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
|
|
2057
|
+
// `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
|
|
2058
|
+
// backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
|
|
2059
|
+
// Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
|
|
2060
|
+
// JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
|
|
2061
|
+
//
|
|
2062
|
+
// The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
|
|
2063
|
+
// base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
|
|
2064
|
+
// around it: a variable-length suffix would either run past the key into adjacent syntax or stop
|
|
2065
|
+
// short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
|
|
2066
|
+
// characters, which is not this format.
|
|
2067
|
+
prefilter: ["sb_secret_"],
|
|
2068
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
|
|
2069
|
+
},
|
|
2070
|
+
{
|
|
2071
|
+
id: "resp-supabase-service-role-key",
|
|
2072
|
+
title: "Supabase service_role key in response body",
|
|
2073
|
+
phase: "response",
|
|
2074
|
+
category: "secret-exposure",
|
|
2075
|
+
action: "redact",
|
|
2076
|
+
// Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
|
|
2077
|
+
// entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
|
|
2078
|
+
// by design and a login response carries the user's own access token — so this decides on the
|
|
2079
|
+
// decoded payload rather than on the shape of the token.
|
|
2080
|
+
//
|
|
2081
|
+
// `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
|
|
2082
|
+
// tokens and serve the rest of the response.
|
|
2083
|
+
//
|
|
2084
|
+
// A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
|
|
2085
|
+
// an opt-in, observe-only rule rather than an on-by-default mask.
|
|
1975
2086
|
prefilter: ["eyJ"],
|
|
1976
|
-
rule_v2: [{ parameter: "response.body", match: { type: "
|
|
2087
|
+
rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
|
|
1977
2088
|
},
|
|
1978
2089
|
{
|
|
1979
2090
|
id: "resp-db-connection-string",
|
|
@@ -2198,25 +2309,468 @@ function toEnvelope(value) {
|
|
|
2198
2309
|
return null;
|
|
2199
2310
|
}
|
|
2200
2311
|
|
|
2201
|
-
// src/protect/rules/
|
|
2202
|
-
var
|
|
2312
|
+
// src/protect/rules/contract.js
|
|
2313
|
+
var SOURCES = Object.freeze({
|
|
2314
|
+
raw: Object.freeze({ keyed: false }),
|
|
2315
|
+
all: Object.freeze({ keyed: false }),
|
|
2316
|
+
false: Object.freeze({ keyed: false }),
|
|
2317
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
2318
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2319
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2320
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2321
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2322
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
2323
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
2324
|
+
files: Object.freeze({
|
|
2325
|
+
keyed: true,
|
|
2326
|
+
keys: "any",
|
|
2327
|
+
wildcard: true,
|
|
2328
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
2329
|
+
}),
|
|
2330
|
+
server: Object.freeze({
|
|
2331
|
+
keyed: true,
|
|
2332
|
+
keys: Object.freeze([
|
|
2333
|
+
"REQUEST_URI",
|
|
2334
|
+
"REQUEST_METHOD",
|
|
2335
|
+
"HTTP_USER_AGENT",
|
|
2336
|
+
"HTTP_REFERER",
|
|
2337
|
+
"HTTP_HOST",
|
|
2338
|
+
"REMOTE_ADDR",
|
|
2339
|
+
"ip",
|
|
2340
|
+
"CONTENT_TYPE",
|
|
2341
|
+
"CONTENT_LENGTH"
|
|
2342
|
+
]),
|
|
2343
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
2344
|
+
}),
|
|
2345
|
+
response: Object.freeze({
|
|
2346
|
+
keyed: true,
|
|
2347
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
2348
|
+
key_prefixes: Object.freeze(["header."])
|
|
2349
|
+
}),
|
|
2350
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
2351
|
+
});
|
|
2352
|
+
var MATCH_TYPES = Object.freeze([
|
|
2353
|
+
"array_in_array",
|
|
2354
|
+
"array_key_value",
|
|
2355
|
+
"contains",
|
|
2356
|
+
"cors_reflected",
|
|
2357
|
+
"cross_origin",
|
|
2358
|
+
"ctype_alnum",
|
|
2359
|
+
"ctype_digit",
|
|
2360
|
+
"ctype_special",
|
|
2361
|
+
"equals",
|
|
2362
|
+
"equals_strict",
|
|
2363
|
+
"hostname",
|
|
2364
|
+
"in_array",
|
|
2365
|
+
"inline_js_xss",
|
|
2366
|
+
"inline_xss",
|
|
2367
|
+
"internal_host",
|
|
2368
|
+
"is_numeric",
|
|
2369
|
+
"isset",
|
|
2370
|
+
"jwt_claim_equals",
|
|
2371
|
+
"less_than",
|
|
2372
|
+
"more_than",
|
|
2373
|
+
"not_contains",
|
|
2374
|
+
"not_in_array",
|
|
2375
|
+
"off_origin",
|
|
2376
|
+
"quotes",
|
|
2377
|
+
"regex",
|
|
2378
|
+
"stripos"
|
|
2379
|
+
]);
|
|
2380
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
2381
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
2382
|
+
"isset",
|
|
2383
|
+
"quotes",
|
|
2384
|
+
"is_numeric",
|
|
2385
|
+
"ctype_alnum",
|
|
2386
|
+
"ctype_digit",
|
|
2387
|
+
"ctype_special",
|
|
2388
|
+
"inline_xss",
|
|
2389
|
+
"inline_js_xss",
|
|
2390
|
+
"internal_host",
|
|
2391
|
+
"cross_origin",
|
|
2392
|
+
"off_origin",
|
|
2393
|
+
"cors_reflected"
|
|
2394
|
+
]);
|
|
2395
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
2396
|
+
"positive-number",
|
|
2397
|
+
"boolean",
|
|
2398
|
+
"non-empty-string",
|
|
2399
|
+
"regex-literal",
|
|
2400
|
+
"string-or-string-list",
|
|
2401
|
+
"scalar",
|
|
2402
|
+
"string",
|
|
2403
|
+
"numeric",
|
|
2404
|
+
"scalar-or-non-empty-list",
|
|
2405
|
+
"non-empty-list",
|
|
2406
|
+
"object",
|
|
2407
|
+
"non-empty-object",
|
|
2408
|
+
"non-empty-string-list",
|
|
2409
|
+
"header-name-map",
|
|
2410
|
+
"header-name-list",
|
|
2411
|
+
"cookie-flags"
|
|
2412
|
+
]);
|
|
2413
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
2414
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
2415
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2416
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2417
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
2418
|
+
array_key_value: Object.freeze({
|
|
2419
|
+
required: Object.freeze(["key", "match"]),
|
|
2420
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
2421
|
+
}),
|
|
2422
|
+
// Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
|
|
2423
|
+
// `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
|
|
2424
|
+
// exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
|
|
2425
|
+
// oversized, claim missing, claim not a string, or a different value — is a no-match.
|
|
2426
|
+
jwt_claim_equals: Object.freeze({
|
|
2427
|
+
required: Object.freeze(["claim", "value"]),
|
|
2428
|
+
shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
|
|
2429
|
+
}),
|
|
2430
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
2431
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2432
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2433
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2434
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
2435
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
2436
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
2437
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
2438
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2439
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2440
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2441
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
2442
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
2443
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
2444
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
2445
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
2446
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
2447
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
2448
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
2449
|
+
});
|
|
2450
|
+
function operandShapeProblem(shape, value) {
|
|
2451
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
2452
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
2453
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
2454
|
+
switch (shape) {
|
|
2455
|
+
case "positive-number":
|
|
2456
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
|
|
2457
|
+
case "boolean":
|
|
2458
|
+
return typeof value === "boolean" ? null : "must be true or false";
|
|
2459
|
+
case "non-empty-string":
|
|
2460
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
2461
|
+
case "regex-literal":
|
|
2462
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
2463
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
2464
|
+
case "string-or-string-list":
|
|
2465
|
+
if (isFilledString(value)) return null;
|
|
2466
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
2467
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
2468
|
+
case "scalar":
|
|
2469
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
2470
|
+
case "string":
|
|
2471
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
2472
|
+
case "numeric":
|
|
2473
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
2474
|
+
case "scalar-or-non-empty-list":
|
|
2475
|
+
if (isScalar(value)) return null;
|
|
2476
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
2477
|
+
return "must be a scalar, or a non-empty list of them";
|
|
2478
|
+
case "non-empty-list":
|
|
2479
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
2480
|
+
case "header-name-map": {
|
|
2481
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2482
|
+
const names = Object.keys(value);
|
|
2483
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
2484
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
2485
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2486
|
+
}
|
|
2487
|
+
case "header-name-list": {
|
|
2488
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
2489
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
2490
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2491
|
+
}
|
|
2492
|
+
case "cookie-flags": {
|
|
2493
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2494
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
2495
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
2496
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
2497
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
2498
|
+
return `flag "${flag}" must be true or false`;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
const sameSite = value.sameSite;
|
|
2502
|
+
const sameSiteOff = sameSite === false;
|
|
2503
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
2504
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
2505
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
2509
|
+
return "turns off every flag, so it would harden nothing";
|
|
2510
|
+
}
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
case "object":
|
|
2514
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
2515
|
+
case "non-empty-object":
|
|
2516
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
2517
|
+
case "non-empty-string-list":
|
|
2518
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
2519
|
+
default:
|
|
2520
|
+
return null;
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
var MUTATIONS = Object.freeze([
|
|
2524
|
+
"base64_decode",
|
|
2525
|
+
"getArrayValues",
|
|
2526
|
+
"htmlentitydecode",
|
|
2527
|
+
"intval",
|
|
2528
|
+
"json_decode",
|
|
2529
|
+
"json_encode",
|
|
2530
|
+
"urldecode"
|
|
2531
|
+
]);
|
|
2532
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
2533
|
+
var ACTIONS = Object.freeze([
|
|
2534
|
+
"block",
|
|
2535
|
+
"redact",
|
|
2536
|
+
"encode",
|
|
2537
|
+
"set-header",
|
|
2538
|
+
"remove-header",
|
|
2539
|
+
"harden-cookie"
|
|
2540
|
+
]);
|
|
2541
|
+
var RULE_PROPERTY_SHAPES = Object.freeze({
|
|
2542
|
+
max_bytes: "positive-number",
|
|
2543
|
+
bypass_limit: "boolean",
|
|
2544
|
+
ensure: "boolean",
|
|
2545
|
+
prefilter: "non-empty-string-list"
|
|
2546
|
+
});
|
|
2547
|
+
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
2548
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
2549
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
2550
|
+
"id",
|
|
2551
|
+
"rule_id",
|
|
2552
|
+
"title",
|
|
2553
|
+
"category",
|
|
2554
|
+
"phase",
|
|
2555
|
+
"action",
|
|
2556
|
+
"rule_v2",
|
|
2557
|
+
"when",
|
|
2558
|
+
"message",
|
|
2559
|
+
"enforcement",
|
|
2560
|
+
"source_revision",
|
|
2561
|
+
"prefilter",
|
|
2562
|
+
"max_bytes",
|
|
2563
|
+
"bypass_limit",
|
|
2564
|
+
"set_headers",
|
|
2565
|
+
"remove_headers",
|
|
2566
|
+
"cookie_flags",
|
|
2567
|
+
"ensure"
|
|
2568
|
+
]);
|
|
2569
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
2570
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
2571
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2572
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2573
|
+
"set-header": Object.freeze({
|
|
2574
|
+
required: Object.freeze(["set_headers"]),
|
|
2575
|
+
defaulted: Object.freeze(["ensure"]),
|
|
2576
|
+
phases: Object.freeze(["response"]),
|
|
2577
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
2578
|
+
}),
|
|
2579
|
+
"remove-header": Object.freeze({
|
|
2580
|
+
required: Object.freeze(["remove_headers"]),
|
|
2581
|
+
defaulted: Object.freeze([]),
|
|
2582
|
+
phases: Object.freeze(["response"]),
|
|
2583
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
2584
|
+
}),
|
|
2585
|
+
"harden-cookie": Object.freeze({
|
|
2586
|
+
required: Object.freeze([]),
|
|
2587
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
2588
|
+
phases: Object.freeze(["response"]),
|
|
2589
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
2590
|
+
})
|
|
2591
|
+
});
|
|
2592
|
+
var LIMITS = Object.freeze({
|
|
2203
2593
|
maxRules: 5e3,
|
|
2204
2594
|
maxWhitelists: 2e3,
|
|
2205
2595
|
maxConditionsPerRule: 250,
|
|
2206
2596
|
maxNestingDepth: 12,
|
|
2207
2597
|
maxRegexLength: 1e3,
|
|
2208
2598
|
maxValueLength: 8192
|
|
2209
|
-
};
|
|
2210
|
-
var
|
|
2211
|
-
|
|
2599
|
+
});
|
|
2600
|
+
var GROUP_PARAMETER = "rules";
|
|
2601
|
+
function isGroup(condition) {
|
|
2602
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
2603
|
+
}
|
|
2604
|
+
function conditionShapeProblem(condition) {
|
|
2605
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
2606
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
2607
|
+
const nested = Array.isArray(condition.rules);
|
|
2608
|
+
if (named && !nested) {
|
|
2609
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
2610
|
+
}
|
|
2611
|
+
if (nested && !named) {
|
|
2612
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2613
|
+
}
|
|
2614
|
+
if (named && nested) {
|
|
2615
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2616
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
2617
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
2618
|
+
}
|
|
2619
|
+
return null;
|
|
2620
|
+
}
|
|
2621
|
+
function parameterProblem(parameter) {
|
|
2622
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
2623
|
+
if (Array.isArray(parameter)) {
|
|
2624
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
2625
|
+
for (const member of parameter) {
|
|
2626
|
+
const problem = parameterProblem(member);
|
|
2627
|
+
if (problem !== null) return problem;
|
|
2628
|
+
}
|
|
2629
|
+
return null;
|
|
2630
|
+
}
|
|
2631
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
2632
|
+
if (parameter === GROUP_PARAMETER) {
|
|
2633
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
2634
|
+
}
|
|
2635
|
+
const dot = parameter.indexOf(".");
|
|
2636
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
2637
|
+
const source = SOURCES[name];
|
|
2638
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
2639
|
+
if (dot === -1) {
|
|
2640
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
2641
|
+
}
|
|
2642
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
2643
|
+
const key = parameter.slice(dot + 1);
|
|
2644
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
2645
|
+
return keyProblem(name, source, key);
|
|
2646
|
+
}
|
|
2647
|
+
function keyProblem(name, source, key) {
|
|
2648
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
2649
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
2650
|
+
}
|
|
2651
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
2652
|
+
if (source.keys === "any") return null;
|
|
2653
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
2654
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
2655
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
2656
|
+
}
|
|
2657
|
+
const accepted = [
|
|
2658
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
2659
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2660
|
+
].join(", ");
|
|
2661
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2662
|
+
}
|
|
2663
|
+
function matchProblem(match, parameter) {
|
|
2664
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
2665
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
2666
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
2667
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
2668
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
2669
|
+
return `match type "${match.type}" needs a parameter`;
|
|
2670
|
+
}
|
|
2671
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
2672
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
2673
|
+
}
|
|
2674
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
2675
|
+
if (operands) {
|
|
2676
|
+
for (const required of operands.required) {
|
|
2677
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
2678
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
2682
|
+
if (match[operand] === void 0) continue;
|
|
2683
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
2684
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
2685
|
+
}
|
|
2686
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
2687
|
+
return `match type "${match.type}" needs a value`;
|
|
2688
|
+
}
|
|
2689
|
+
return null;
|
|
2690
|
+
}
|
|
2691
|
+
function mutationsProblem(mutations) {
|
|
2692
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
2693
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
2694
|
+
for (const mutation of mutations) {
|
|
2695
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
2696
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
return null;
|
|
2700
|
+
}
|
|
2701
|
+
function whenProblem(when) {
|
|
2702
|
+
if (when === void 0 || when === null) return null;
|
|
2703
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
2704
|
+
const keys = Object.keys(when);
|
|
2705
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
2706
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
2707
|
+
if (unknown.length > 0) {
|
|
2708
|
+
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`;
|
|
2709
|
+
}
|
|
2710
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
2711
|
+
return "when.path must be a non-empty string";
|
|
2712
|
+
}
|
|
2713
|
+
if ("method" in when) {
|
|
2714
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
2715
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
2716
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
2717
|
+
}
|
|
2718
|
+
return null;
|
|
2719
|
+
}
|
|
2720
|
+
function isNonEmptyString(value) {
|
|
2721
|
+
return typeof value === "string" && value.trim() !== "";
|
|
2722
|
+
}
|
|
2723
|
+
function nullPropertyProblem(rule) {
|
|
2724
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2725
|
+
for (const property of RULE_PROPERTIES) {
|
|
2726
|
+
if (property in rule && rule[property] === null) {
|
|
2727
|
+
return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return null;
|
|
2731
|
+
}
|
|
2732
|
+
function rulePropertyProblem(rule) {
|
|
2733
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2734
|
+
for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
|
|
2735
|
+
if (rule[property] === void 0 || rule[property] === null) continue;
|
|
2736
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2737
|
+
if (problem) return `"${property}" ${problem}`;
|
|
2738
|
+
}
|
|
2739
|
+
return null;
|
|
2740
|
+
}
|
|
2741
|
+
function actionProblem(action, rule) {
|
|
2742
|
+
if (action === void 0 || action === null) return null;
|
|
2743
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2744
|
+
const spec = ACTION_PROPERTIES[action];
|
|
2745
|
+
for (const required of spec?.required ?? []) {
|
|
2746
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2747
|
+
return `action "${action}" needs "${required}"`;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2751
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2752
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2753
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2754
|
+
}
|
|
2755
|
+
const phase = rule?.phase ?? "request";
|
|
2756
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2757
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2758
|
+
}
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/protect/rules/validate.js
|
|
2763
|
+
var LIMITS2 = LIMITS;
|
|
2764
|
+
var PHASES2 = new Set(PHASES);
|
|
2765
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
2212
2766
|
function validateBundle(bundle, opts = {}) {
|
|
2213
2767
|
const rejected = [];
|
|
2214
2768
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2215
2769
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2216
2770
|
const firewall = [];
|
|
2217
2771
|
for (const rule of inFirewall) {
|
|
2218
|
-
if (firewall.length >=
|
|
2219
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
2772
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
2773
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2220
2774
|
continue;
|
|
2221
2775
|
}
|
|
2222
2776
|
const reason = ruleProblem(rule);
|
|
@@ -2225,8 +2779,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
2225
2779
|
}
|
|
2226
2780
|
const whitelists = [];
|
|
2227
2781
|
for (const wl of inWhitelists) {
|
|
2228
|
-
if (whitelists.length >=
|
|
2229
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
2782
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
2783
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
2230
2784
|
continue;
|
|
2231
2785
|
}
|
|
2232
2786
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -2248,37 +2802,47 @@ function idOf(rule) {
|
|
|
2248
2802
|
}
|
|
2249
2803
|
function ruleProblem(rule) {
|
|
2250
2804
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
2251
|
-
|
|
2252
|
-
if (
|
|
2253
|
-
|
|
2254
|
-
|
|
2805
|
+
const nullReason = nullPropertyProblem(rule);
|
|
2806
|
+
if (nullReason) return nullReason;
|
|
2807
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2808
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
2809
|
+
if (actionReason) return actionReason;
|
|
2810
|
+
const scopeReason = whenProblem(rule.when);
|
|
2811
|
+
if (scopeReason) return scopeReason;
|
|
2812
|
+
const propertyReason = rulePropertyProblem(rule);
|
|
2813
|
+
if (propertyReason) return propertyReason;
|
|
2255
2814
|
return conditionsProblem(rule.rule_v2);
|
|
2256
2815
|
}
|
|
2257
2816
|
function conditionsProblem(conditions, depth = 0) {
|
|
2258
2817
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2259
2818
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2260
|
-
if (depth >
|
|
2261
|
-
if (conditions.length >
|
|
2262
|
-
return `more than ${
|
|
2819
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
2820
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
2821
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
2263
2822
|
}
|
|
2264
2823
|
for (const c of conditions) {
|
|
2265
|
-
|
|
2266
|
-
if (
|
|
2824
|
+
const shapeReason = conditionShapeProblem(c);
|
|
2825
|
+
if (shapeReason) return shapeReason;
|
|
2826
|
+
if (isGroup(c)) {
|
|
2267
2827
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2268
2828
|
if (nested) return nested;
|
|
2269
2829
|
continue;
|
|
2270
2830
|
}
|
|
2831
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
2832
|
+
if (parameterReason) return parameterReason;
|
|
2833
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
2834
|
+
if (mutationReason) return mutationReason;
|
|
2835
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
2836
|
+
if (matchReason) return matchReason;
|
|
2271
2837
|
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
2838
|
if (m.type === "regex") {
|
|
2275
2839
|
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 ${
|
|
2840
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2841
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2842
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2279
2843
|
}
|
|
2280
2844
|
if (m.match) {
|
|
2281
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2845
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2282
2846
|
if (nested) return nested;
|
|
2283
2847
|
}
|
|
2284
2848
|
}
|
|
@@ -2306,64 +2870,67 @@ function reportRejections(rejected, options, label) {
|
|
|
2306
2870
|
`${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
2871
|
), "onError");
|
|
2308
2872
|
}
|
|
2873
|
+
function fromSource(bundle, reason) {
|
|
2874
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
2875
|
+
}
|
|
2309
2876
|
async function resolveRules(options, store, ctx = {}) {
|
|
2310
2877
|
const timeoutMs = ctx.timeoutMs;
|
|
2311
2878
|
if (options.siteUuid) {
|
|
2312
2879
|
const prior = await store.read();
|
|
2313
2880
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2314
2881
|
const res = await client.getRules();
|
|
2315
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2882
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2316
2883
|
if (res.success && !res.notModified) {
|
|
2317
2884
|
const rejected = liveUpdateRejections(res, options);
|
|
2318
2885
|
if (rejected.length > 0) {
|
|
2319
2886
|
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();
|
|
2887
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2888
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2889
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2323
2890
|
}
|
|
2324
2891
|
const bundle = normalizeBundle(res, options);
|
|
2325
2892
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2326
|
-
return bundle;
|
|
2893
|
+
return fromSource(bundle);
|
|
2327
2894
|
}
|
|
2328
2895
|
if (prior?.bundle) {
|
|
2329
2896
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2330
|
-
return normalizeBundle(prior.bundle, options);
|
|
2897
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2331
2898
|
}
|
|
2332
2899
|
if (options.rules) {
|
|
2333
2900
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2334
|
-
return normalizeBundle(options.rules, options);
|
|
2901
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
2335
2902
|
}
|
|
2336
2903
|
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();
|
|
2904
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2338
2905
|
}
|
|
2339
2906
|
if (options.token) {
|
|
2340
2907
|
const prior = await store.read();
|
|
2341
2908
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2342
2909
|
const res = await client.getRules();
|
|
2343
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2910
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2344
2911
|
if (res.success && !res.notModified) {
|
|
2345
2912
|
const rejected = liveUpdateRejections(res, options);
|
|
2346
2913
|
if (rejected.length > 0) {
|
|
2347
2914
|
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();
|
|
2915
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2916
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2917
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2351
2918
|
}
|
|
2352
2919
|
const bundle = normalizeBundle(res, options);
|
|
2353
2920
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2354
|
-
return bundle;
|
|
2921
|
+
return fromSource(bundle);
|
|
2355
2922
|
}
|
|
2356
2923
|
if (prior?.bundle) {
|
|
2357
2924
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2358
|
-
return normalizeBundle(prior.bundle, options);
|
|
2925
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2359
2926
|
}
|
|
2360
2927
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2361
|
-
return emptyBundle();
|
|
2928
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2362
2929
|
}
|
|
2363
2930
|
if (options.rules) {
|
|
2364
|
-
return normalizeBundle(options.rules, options);
|
|
2931
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
2365
2932
|
}
|
|
2366
|
-
return emptyBundle();
|
|
2933
|
+
return fromSource(emptyBundle());
|
|
2367
2934
|
}
|
|
2368
2935
|
function normalizeBundle(b, options = {}) {
|
|
2369
2936
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -2416,8 +2983,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2416
2983
|
const run = async () => {
|
|
2417
2984
|
if (stopped) return;
|
|
2418
2985
|
try {
|
|
2419
|
-
await tick();
|
|
2420
|
-
|
|
2986
|
+
const status = await tick();
|
|
2987
|
+
if (status && status.ok === false) failures++;
|
|
2988
|
+
else failures = 0;
|
|
2421
2989
|
} catch (err) {
|
|
2422
2990
|
failures++;
|
|
2423
2991
|
notify(onError, err, "onError");
|
|
@@ -2444,7 +3012,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2444
3012
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
2445
3013
|
let refreshed = true;
|
|
2446
3014
|
try {
|
|
2447
|
-
await tick();
|
|
3015
|
+
const status = await tick();
|
|
3016
|
+
if (status && status.ok === false) refreshed = false;
|
|
2448
3017
|
} catch {
|
|
2449
3018
|
refreshed = false;
|
|
2450
3019
|
}
|
|
@@ -2472,6 +3041,12 @@ function ruleParameters(rule) {
|
|
|
2472
3041
|
walk(rule?.rule_v2);
|
|
2473
3042
|
return [...out];
|
|
2474
3043
|
}
|
|
3044
|
+
function revisionOf(rule) {
|
|
3045
|
+
const revision = rule?.source_revision;
|
|
3046
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
3047
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
3048
|
+
return null;
|
|
3049
|
+
}
|
|
2475
3050
|
function routeOf(path) {
|
|
2476
3051
|
if (typeof path !== "string" || path === "") return null;
|
|
2477
3052
|
const cut = path.search(/[?#]/);
|
|
@@ -2480,16 +3055,30 @@ function routeOf(path) {
|
|
|
2480
3055
|
function createDetectionReporter(opts) {
|
|
2481
3056
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2482
3057
|
if (!siteUuid) {
|
|
2483
|
-
return {
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
3058
|
+
return {
|
|
3059
|
+
record() {
|
|
3060
|
+
},
|
|
3061
|
+
flush() {
|
|
3062
|
+
},
|
|
3063
|
+
stop() {
|
|
3064
|
+
},
|
|
3065
|
+
setRulesEtag() {
|
|
3066
|
+
},
|
|
3067
|
+
dropped: () => 0,
|
|
3068
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
3069
|
+
};
|
|
2487
3070
|
}
|
|
2488
3071
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2489
3072
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2490
3073
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2491
3074
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2492
3075
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
3076
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
3077
|
+
let sent = 0;
|
|
3078
|
+
let delivered = 0;
|
|
3079
|
+
let failed = 0;
|
|
3080
|
+
let droppedTotal = 0;
|
|
3081
|
+
let lastDeliveredAt = null;
|
|
2493
3082
|
let queue = [];
|
|
2494
3083
|
let timer = null;
|
|
2495
3084
|
let stopped = false;
|
|
@@ -2503,6 +3092,8 @@ function createDetectionReporter(opts) {
|
|
|
2503
3092
|
const batch = queue.splice(0, MAX_BATCH);
|
|
2504
3093
|
const droppedWith = dropped;
|
|
2505
3094
|
dropped = 0;
|
|
3095
|
+
droppedTotal += droppedWith;
|
|
3096
|
+
sent += batch.length;
|
|
2506
3097
|
void (async () => {
|
|
2507
3098
|
try {
|
|
2508
3099
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -2511,15 +3102,22 @@ function createDetectionReporter(opts) {
|
|
|
2511
3102
|
"Content-Type": "application/json",
|
|
2512
3103
|
Accept: "application/json",
|
|
2513
3104
|
"User-Agent": "@patchstack/connect",
|
|
2514
|
-
// Same credential path as the rules fetch
|
|
2515
|
-
//
|
|
3105
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
3106
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
3107
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
3108
|
+
// posting into a 401.
|
|
2516
3109
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2517
3110
|
},
|
|
2518
3111
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2519
3112
|
});
|
|
2520
|
-
if (res &&
|
|
2521
|
-
|
|
3113
|
+
if (res && res.ok) {
|
|
3114
|
+
delivered += batch.length;
|
|
3115
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3116
|
+
} else {
|
|
3117
|
+
failed += batch.length;
|
|
3118
|
+
}
|
|
2522
3119
|
} catch {
|
|
3120
|
+
failed += batch.length;
|
|
2523
3121
|
}
|
|
2524
3122
|
})();
|
|
2525
3123
|
};
|
|
@@ -2548,7 +3146,12 @@ function createDetectionReporter(opts) {
|
|
|
2548
3146
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2549
3147
|
// saw traffic it would have stopped.
|
|
2550
3148
|
enforced: detection.mode === "block",
|
|
2551
|
-
rules_etag:
|
|
3149
|
+
rules_etag: rulesEtag,
|
|
3150
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
3151
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
3152
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
3153
|
+
// bundle carried none.
|
|
3154
|
+
rule_revision: revisionOf(detection.rule),
|
|
2552
3155
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2553
3156
|
});
|
|
2554
3157
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -2562,7 +3165,24 @@ function createDetectionReporter(opts) {
|
|
|
2562
3165
|
stopped = true;
|
|
2563
3166
|
flush();
|
|
2564
3167
|
},
|
|
2565
|
-
|
|
3168
|
+
/**
|
|
3169
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
3170
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
3171
|
+
*
|
|
3172
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
3173
|
+
* the only moment the two are known to agree.
|
|
3174
|
+
*
|
|
3175
|
+
* @param {string | null | undefined} next
|
|
3176
|
+
*/
|
|
3177
|
+
setRulesEtag(next) {
|
|
3178
|
+
rulesEtag = next ?? null;
|
|
3179
|
+
},
|
|
3180
|
+
dropped: () => dropped,
|
|
3181
|
+
/**
|
|
3182
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
3183
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
3184
|
+
*/
|
|
3185
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
2566
3186
|
};
|
|
2567
3187
|
}
|
|
2568
3188
|
|
|
@@ -2827,22 +3447,32 @@ async function createProtection(options = {}) {
|
|
|
2827
3447
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2828
3448
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2829
3449
|
if (options.siteUuid && !pulseAuth) {
|
|
2830
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
3450
|
+
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
3451
|
notify(onError, new Error(message), "onError");
|
|
2832
3452
|
console.warn(message);
|
|
2833
3453
|
}
|
|
2834
3454
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
3455
|
+
let detectionReporting = "off";
|
|
2835
3456
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
3457
|
+
if (!pulseAuth) {
|
|
3458
|
+
detectionReporting = "unavailable-no-credential";
|
|
3459
|
+
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.";
|
|
3460
|
+
notify(onError, new Error(message), "onError");
|
|
3461
|
+
console.warn(message);
|
|
3462
|
+
} else {
|
|
3463
|
+
detectionReporting = "on";
|
|
3464
|
+
detections = createDetectionReporter({
|
|
3465
|
+
siteUuid: options.siteUuid,
|
|
3466
|
+
baseUrl: options.pulseRulesUrl,
|
|
3467
|
+
pulseAuth,
|
|
3468
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
3469
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
3470
|
+
// see the refresh tick below.
|
|
3471
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
3472
|
+
fetchImpl: options.fetchImpl,
|
|
3473
|
+
flushMs: options.detectionFlushMs
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
2846
3476
|
}
|
|
2847
3477
|
let mode = resolveMode(options, bundle);
|
|
2848
3478
|
let requestRules;
|
|
@@ -3176,11 +3806,17 @@ async function createProtection(options = {}) {
|
|
|
3176
3806
|
);
|
|
3177
3807
|
};
|
|
3178
3808
|
},
|
|
3179
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
3809
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
3810
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
3811
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
3180
3812
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
3181
3813
|
node(nodeOptions = {}) {
|
|
3182
3814
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
3183
3815
|
return (req, res, next) => {
|
|
3816
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
3817
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3184
3820
|
const chunks = [];
|
|
3185
3821
|
let size = 0;
|
|
3186
3822
|
let overflow = false;
|
|
@@ -3198,38 +3834,41 @@ async function createProtection(options = {}) {
|
|
|
3198
3834
|
});
|
|
3199
3835
|
req.on("end", () => {
|
|
3200
3836
|
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
|
-
);
|
|
3837
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
3231
3838
|
});
|
|
3232
3839
|
};
|
|
3840
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
3841
|
+
let shaped;
|
|
3842
|
+
let result;
|
|
3843
|
+
try {
|
|
3844
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
3845
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
3846
|
+
result = engine.evaluate(shaped);
|
|
3847
|
+
} catch (err) {
|
|
3848
|
+
notify(onError, err, "onError");
|
|
3849
|
+
return next();
|
|
3850
|
+
}
|
|
3851
|
+
decide(
|
|
3852
|
+
"request",
|
|
3853
|
+
result,
|
|
3854
|
+
() => {
|
|
3855
|
+
res.statusCode = 403;
|
|
3856
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3857
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3858
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3859
|
+
} else {
|
|
3860
|
+
res.setHeader("content-type", "application/json");
|
|
3861
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
3862
|
+
}
|
|
3863
|
+
},
|
|
3864
|
+
() => {
|
|
3865
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
3866
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3867
|
+
next();
|
|
3868
|
+
},
|
|
3869
|
+
nodeRequestMeta(req)
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3233
3872
|
}
|
|
3234
3873
|
};
|
|
3235
3874
|
if (options.egress) {
|
|
@@ -3266,24 +3905,22 @@ async function createProtection(options = {}) {
|
|
|
3266
3905
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3267
3906
|
mode = resolveMode(options, next);
|
|
3268
3907
|
applyBundle(next);
|
|
3908
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
3909
|
+
return next.source ?? { ok: true };
|
|
3269
3910
|
};
|
|
3270
3911
|
if (live) {
|
|
3271
3912
|
protection.refresh = () => runRefreshTick();
|
|
3272
3913
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
3273
3914
|
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
firewallLog.stop();
|
|
3284
|
-
detections?.stop();
|
|
3285
|
-
};
|
|
3286
|
-
}
|
|
3915
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
3916
|
+
protection.stop = () => {
|
|
3917
|
+
loop?.stop();
|
|
3918
|
+
firewallLog?.stop();
|
|
3919
|
+
detections?.stop();
|
|
3920
|
+
};
|
|
3921
|
+
protection.stopRefresh = protection.stop;
|
|
3922
|
+
protection.detectionReporting = detectionReporting;
|
|
3923
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
3287
3924
|
return protection;
|
|
3288
3925
|
}
|
|
3289
3926
|
function resolveMode(options, bundle) {
|
|
@@ -3294,22 +3931,35 @@ function resolveMode(options, bundle) {
|
|
|
3294
3931
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3295
3932
|
return "dry-run";
|
|
3296
3933
|
}
|
|
3934
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
3935
|
+
async function readCredentialField(options, field) {
|
|
3936
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3937
|
+
let readFileSync;
|
|
3938
|
+
let join;
|
|
3939
|
+
try {
|
|
3940
|
+
const [fs, pathMod] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
|
|
3941
|
+
readFileSync = fs.readFileSync;
|
|
3942
|
+
join = pathMod.join;
|
|
3943
|
+
} catch {
|
|
3944
|
+
return void 0;
|
|
3945
|
+
}
|
|
3946
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3947
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
3948
|
+
try {
|
|
3949
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
3950
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3951
|
+
} catch {
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
return void 0;
|
|
3955
|
+
}
|
|
3297
3956
|
async function resolveApiKey(options) {
|
|
3298
3957
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3299
3958
|
if (typeof process !== "undefined") {
|
|
3300
3959
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3301
3960
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3302
3961
|
}
|
|
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;
|
|
3962
|
+
return readCredentialField(options, "apiKey");
|
|
3313
3963
|
}
|
|
3314
3964
|
async function resolvePulseAuth(options) {
|
|
3315
3965
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -3317,15 +3967,8 @@ async function resolvePulseAuth(options) {
|
|
|
3317
3967
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3318
3968
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3319
3969
|
}
|
|
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
|
-
}
|
|
3970
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
3971
|
+
if (fromFile !== void 0) return fromFile;
|
|
3329
3972
|
return resolveApiKey(options);
|
|
3330
3973
|
}
|
|
3331
3974
|
function byPhase(rules, phase) {
|
|
@@ -3458,6 +4101,8 @@ function extractRedactors(rule) {
|
|
|
3458
4101
|
}
|
|
3459
4102
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
3460
4103
|
out.push({ literal: String(m.value) });
|
|
4104
|
+
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
4105
|
+
out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
|
|
3461
4106
|
} else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
|
|
3462
4107
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
3463
4108
|
for (const key of keys) {
|
|
@@ -3476,7 +4121,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
3476
4121
|
let out = body;
|
|
3477
4122
|
for (const r of redactors) {
|
|
3478
4123
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
3479
|
-
else if (r.
|
|
4124
|
+
else if (r.jwtClaim) {
|
|
4125
|
+
for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
|
|
4126
|
+
out = out.split(token).join(transform ? transform(token) : mask);
|
|
4127
|
+
}
|
|
4128
|
+
} else if (r.literal) {
|
|
3480
4129
|
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
3481
4130
|
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
3482
4131
|
}
|
|
@@ -3610,11 +4259,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
3610
4259
|
}
|
|
3611
4260
|
}
|
|
3612
4261
|
}
|
|
4262
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
3613
4263
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3614
4264
|
let out = String(cookie);
|
|
3615
4265
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3616
4266
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3617
|
-
|
|
4267
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
4268
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
3618
4269
|
return out;
|
|
3619
4270
|
}
|
|
3620
4271
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -3628,11 +4279,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3628
4279
|
} catch {
|
|
3629
4280
|
}
|
|
3630
4281
|
} else if (typeof value === "string") {
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
} catch {
|
|
3635
|
-
}
|
|
4282
|
+
try {
|
|
4283
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
4284
|
+
} catch {
|
|
3636
4285
|
}
|
|
3637
4286
|
} else if (Array.isArray(value)) {
|
|
3638
4287
|
try {
|