@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.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":
|
|
@@ -920,6 +961,30 @@ function expandIPv6(host) {
|
|
|
920
961
|
}
|
|
921
962
|
return out;
|
|
922
963
|
}
|
|
964
|
+
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;
|
|
965
|
+
function base64UrlToBase64(segment) {
|
|
966
|
+
const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
|
|
967
|
+
return padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
968
|
+
}
|
|
969
|
+
function jwtClaimSpans(text, claim, value) {
|
|
970
|
+
const found = /* @__PURE__ */ new Set();
|
|
971
|
+
if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
|
|
972
|
+
const wanted = String(value);
|
|
973
|
+
for (const [token] of text.matchAll(JWT_CANDIDATE)) {
|
|
974
|
+
const parts = token.split(".");
|
|
975
|
+
let payload;
|
|
976
|
+
try {
|
|
977
|
+
payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
|
|
978
|
+
} catch {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
982
|
+
if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
|
|
983
|
+
const actual = payload[claim];
|
|
984
|
+
if (typeof actual === "string" && actual === wanted) found.add(token);
|
|
985
|
+
}
|
|
986
|
+
return [...found];
|
|
987
|
+
}
|
|
923
988
|
var warnedScopes = /* @__PURE__ */ new Set();
|
|
924
989
|
function warnUnrecognisedScope(when) {
|
|
925
990
|
const key = Object.keys(when).sort().join(",");
|
|
@@ -948,16 +1013,30 @@ function ruleAppliesTo(when, resolver) {
|
|
|
948
1013
|
return true;
|
|
949
1014
|
}
|
|
950
1015
|
}
|
|
1016
|
+
function foldPathForScope(value) {
|
|
1017
|
+
const out = String(value).toLowerCase();
|
|
1018
|
+
return out.endsWith("/") ? out.slice(0, -1) : out;
|
|
1019
|
+
}
|
|
1020
|
+
var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
|
|
1021
|
+
var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
1022
|
+
function isRegexForm(pattern) {
|
|
1023
|
+
const parts = REGEX_FORM.exec(pattern);
|
|
1024
|
+
return parts !== null && REGEX_SYNTAX.test(parts[1]);
|
|
1025
|
+
}
|
|
951
1026
|
function pathMatches(pattern, path) {
|
|
952
|
-
if (
|
|
1027
|
+
if (isRegexForm(pattern)) {
|
|
953
1028
|
const re = safeRegExp(pattern);
|
|
954
1029
|
return re ? re.test(path) : false;
|
|
955
1030
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1031
|
+
const target = foldPathForScope(path);
|
|
1032
|
+
const folded = foldPathForScope(pattern);
|
|
1033
|
+
if (folded.includes("*")) {
|
|
1034
|
+
const trailing = folded.endsWith("/*");
|
|
1035
|
+
const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
|
|
1036
|
+
const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
|
|
1037
|
+
return re ? re.test(target) : false;
|
|
959
1038
|
}
|
|
960
|
-
return
|
|
1039
|
+
return target === folded;
|
|
961
1040
|
}
|
|
962
1041
|
function normalizeDefaultPort(host) {
|
|
963
1042
|
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
@@ -1110,6 +1189,8 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1110
1189
|
const stripped = strValue.replace(/[ _\-,]/g, "");
|
|
1111
1190
|
return ctypeResult(strValue, /^[\w$-]*$/.test(stripped), matchVal);
|
|
1112
1191
|
}
|
|
1192
|
+
case "jwt_claim_equals":
|
|
1193
|
+
return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
|
|
1113
1194
|
case "array_key_value":
|
|
1114
1195
|
return arrayKeyValue(value, matchObj);
|
|
1115
1196
|
// file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
|
|
@@ -1875,13 +1956,43 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1875
1956
|
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])/" } }]
|
|
1876
1957
|
},
|
|
1877
1958
|
{
|
|
1878
|
-
id: "resp-
|
|
1879
|
-
title: "
|
|
1959
|
+
id: "resp-supabase-secret-key",
|
|
1960
|
+
title: "Supabase secret key in response body",
|
|
1961
|
+
phase: "response",
|
|
1962
|
+
category: "secret-exposure",
|
|
1963
|
+
action: "redact",
|
|
1964
|
+
// Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
|
|
1965
|
+
// `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
|
|
1966
|
+
// backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
|
|
1967
|
+
// Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
|
|
1968
|
+
// JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
|
|
1969
|
+
//
|
|
1970
|
+
// The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
|
|
1971
|
+
// base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
|
|
1972
|
+
// around it: a variable-length suffix would either run past the key into adjacent syntax or stop
|
|
1973
|
+
// short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
|
|
1974
|
+
// characters, which is not this format.
|
|
1975
|
+
prefilter: ["sb_secret_"],
|
|
1976
|
+
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_-])/" } }]
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
id: "resp-supabase-service-role-key",
|
|
1980
|
+
title: "Supabase service_role key in response body",
|
|
1880
1981
|
phase: "response",
|
|
1881
1982
|
category: "secret-exposure",
|
|
1882
1983
|
action: "redact",
|
|
1984
|
+
// Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
|
|
1985
|
+
// entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
|
|
1986
|
+
// by design and a login response carries the user's own access token — so this decides on the
|
|
1987
|
+
// decoded payload rather than on the shape of the token.
|
|
1988
|
+
//
|
|
1989
|
+
// `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
|
|
1990
|
+
// tokens and serve the rest of the response.
|
|
1991
|
+
//
|
|
1992
|
+
// A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
|
|
1993
|
+
// an opt-in, observe-only rule rather than an on-by-default mask.
|
|
1883
1994
|
prefilter: ["eyJ"],
|
|
1884
|
-
rule_v2: [{ parameter: "response.body", match: { type: "
|
|
1995
|
+
rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
|
|
1885
1996
|
},
|
|
1886
1997
|
{
|
|
1887
1998
|
id: "resp-db-connection-string",
|
|
@@ -2106,25 +2217,468 @@ function toEnvelope(value) {
|
|
|
2106
2217
|
return null;
|
|
2107
2218
|
}
|
|
2108
2219
|
|
|
2109
|
-
// src/protect/rules/
|
|
2110
|
-
var
|
|
2220
|
+
// src/protect/rules/contract.js
|
|
2221
|
+
var SOURCES = Object.freeze({
|
|
2222
|
+
raw: Object.freeze({ keyed: false }),
|
|
2223
|
+
all: Object.freeze({ keyed: false }),
|
|
2224
|
+
false: Object.freeze({ keyed: false }),
|
|
2225
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
2226
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2227
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2228
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2229
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
2230
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
2231
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
2232
|
+
files: Object.freeze({
|
|
2233
|
+
keyed: true,
|
|
2234
|
+
keys: "any",
|
|
2235
|
+
wildcard: true,
|
|
2236
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
2237
|
+
}),
|
|
2238
|
+
server: Object.freeze({
|
|
2239
|
+
keyed: true,
|
|
2240
|
+
keys: Object.freeze([
|
|
2241
|
+
"REQUEST_URI",
|
|
2242
|
+
"REQUEST_METHOD",
|
|
2243
|
+
"HTTP_USER_AGENT",
|
|
2244
|
+
"HTTP_REFERER",
|
|
2245
|
+
"HTTP_HOST",
|
|
2246
|
+
"REMOTE_ADDR",
|
|
2247
|
+
"ip",
|
|
2248
|
+
"CONTENT_TYPE",
|
|
2249
|
+
"CONTENT_LENGTH"
|
|
2250
|
+
]),
|
|
2251
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
2252
|
+
}),
|
|
2253
|
+
response: Object.freeze({
|
|
2254
|
+
keyed: true,
|
|
2255
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
2256
|
+
key_prefixes: Object.freeze(["header."])
|
|
2257
|
+
}),
|
|
2258
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
2259
|
+
});
|
|
2260
|
+
var MATCH_TYPES = Object.freeze([
|
|
2261
|
+
"array_in_array",
|
|
2262
|
+
"array_key_value",
|
|
2263
|
+
"contains",
|
|
2264
|
+
"cors_reflected",
|
|
2265
|
+
"cross_origin",
|
|
2266
|
+
"ctype_alnum",
|
|
2267
|
+
"ctype_digit",
|
|
2268
|
+
"ctype_special",
|
|
2269
|
+
"equals",
|
|
2270
|
+
"equals_strict",
|
|
2271
|
+
"hostname",
|
|
2272
|
+
"in_array",
|
|
2273
|
+
"inline_js_xss",
|
|
2274
|
+
"inline_xss",
|
|
2275
|
+
"internal_host",
|
|
2276
|
+
"is_numeric",
|
|
2277
|
+
"isset",
|
|
2278
|
+
"jwt_claim_equals",
|
|
2279
|
+
"less_than",
|
|
2280
|
+
"more_than",
|
|
2281
|
+
"not_contains",
|
|
2282
|
+
"not_in_array",
|
|
2283
|
+
"off_origin",
|
|
2284
|
+
"quotes",
|
|
2285
|
+
"regex",
|
|
2286
|
+
"stripos"
|
|
2287
|
+
]);
|
|
2288
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
2289
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
2290
|
+
"isset",
|
|
2291
|
+
"quotes",
|
|
2292
|
+
"is_numeric",
|
|
2293
|
+
"ctype_alnum",
|
|
2294
|
+
"ctype_digit",
|
|
2295
|
+
"ctype_special",
|
|
2296
|
+
"inline_xss",
|
|
2297
|
+
"inline_js_xss",
|
|
2298
|
+
"internal_host",
|
|
2299
|
+
"cross_origin",
|
|
2300
|
+
"off_origin",
|
|
2301
|
+
"cors_reflected"
|
|
2302
|
+
]);
|
|
2303
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
2304
|
+
"positive-number",
|
|
2305
|
+
"boolean",
|
|
2306
|
+
"non-empty-string",
|
|
2307
|
+
"regex-literal",
|
|
2308
|
+
"string-or-string-list",
|
|
2309
|
+
"scalar",
|
|
2310
|
+
"string",
|
|
2311
|
+
"numeric",
|
|
2312
|
+
"scalar-or-non-empty-list",
|
|
2313
|
+
"non-empty-list",
|
|
2314
|
+
"object",
|
|
2315
|
+
"non-empty-object",
|
|
2316
|
+
"non-empty-string-list",
|
|
2317
|
+
"header-name-map",
|
|
2318
|
+
"header-name-list",
|
|
2319
|
+
"cookie-flags"
|
|
2320
|
+
]);
|
|
2321
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
2322
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
2323
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
2324
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2325
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
2326
|
+
array_key_value: Object.freeze({
|
|
2327
|
+
required: Object.freeze(["key", "match"]),
|
|
2328
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
2329
|
+
}),
|
|
2330
|
+
// Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
|
|
2331
|
+
// `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
|
|
2332
|
+
// exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
|
|
2333
|
+
// oversized, claim missing, claim not a string, or a different value — is a no-match.
|
|
2334
|
+
jwt_claim_equals: Object.freeze({
|
|
2335
|
+
required: Object.freeze(["claim", "value"]),
|
|
2336
|
+
shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
|
|
2337
|
+
}),
|
|
2338
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
2339
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2340
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2341
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2342
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
2343
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
2344
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
2345
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
2346
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
2347
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2348
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
2349
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
2350
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
2351
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
2352
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
2353
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
2354
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
2355
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
2356
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
2357
|
+
});
|
|
2358
|
+
function operandShapeProblem(shape, value) {
|
|
2359
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
2360
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
2361
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
2362
|
+
switch (shape) {
|
|
2363
|
+
case "positive-number":
|
|
2364
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
|
|
2365
|
+
case "boolean":
|
|
2366
|
+
return typeof value === "boolean" ? null : "must be true or false";
|
|
2367
|
+
case "non-empty-string":
|
|
2368
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
2369
|
+
case "regex-literal":
|
|
2370
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
2371
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
2372
|
+
case "string-or-string-list":
|
|
2373
|
+
if (isFilledString(value)) return null;
|
|
2374
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
2375
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
2376
|
+
case "scalar":
|
|
2377
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
2378
|
+
case "string":
|
|
2379
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
2380
|
+
case "numeric":
|
|
2381
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
2382
|
+
case "scalar-or-non-empty-list":
|
|
2383
|
+
if (isScalar(value)) return null;
|
|
2384
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
2385
|
+
return "must be a scalar, or a non-empty list of them";
|
|
2386
|
+
case "non-empty-list":
|
|
2387
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
2388
|
+
case "header-name-map": {
|
|
2389
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2390
|
+
const names = Object.keys(value);
|
|
2391
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
2392
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
2393
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2394
|
+
}
|
|
2395
|
+
case "header-name-list": {
|
|
2396
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
2397
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
2398
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
2399
|
+
}
|
|
2400
|
+
case "cookie-flags": {
|
|
2401
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
2402
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
2403
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
2404
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
2405
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
2406
|
+
return `flag "${flag}" must be true or false`;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
const sameSite = value.sameSite;
|
|
2410
|
+
const sameSiteOff = sameSite === false;
|
|
2411
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
2412
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
2413
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
2417
|
+
return "turns off every flag, so it would harden nothing";
|
|
2418
|
+
}
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
case "object":
|
|
2422
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
2423
|
+
case "non-empty-object":
|
|
2424
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
2425
|
+
case "non-empty-string-list":
|
|
2426
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
2427
|
+
default:
|
|
2428
|
+
return null;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
var MUTATIONS = Object.freeze([
|
|
2432
|
+
"base64_decode",
|
|
2433
|
+
"getArrayValues",
|
|
2434
|
+
"htmlentitydecode",
|
|
2435
|
+
"intval",
|
|
2436
|
+
"json_decode",
|
|
2437
|
+
"json_encode",
|
|
2438
|
+
"urldecode"
|
|
2439
|
+
]);
|
|
2440
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
2441
|
+
var ACTIONS = Object.freeze([
|
|
2442
|
+
"block",
|
|
2443
|
+
"redact",
|
|
2444
|
+
"encode",
|
|
2445
|
+
"set-header",
|
|
2446
|
+
"remove-header",
|
|
2447
|
+
"harden-cookie"
|
|
2448
|
+
]);
|
|
2449
|
+
var RULE_PROPERTY_SHAPES = Object.freeze({
|
|
2450
|
+
max_bytes: "positive-number",
|
|
2451
|
+
bypass_limit: "boolean",
|
|
2452
|
+
ensure: "boolean",
|
|
2453
|
+
prefilter: "non-empty-string-list"
|
|
2454
|
+
});
|
|
2455
|
+
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
2456
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
2457
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
2458
|
+
"id",
|
|
2459
|
+
"rule_id",
|
|
2460
|
+
"title",
|
|
2461
|
+
"category",
|
|
2462
|
+
"phase",
|
|
2463
|
+
"action",
|
|
2464
|
+
"rule_v2",
|
|
2465
|
+
"when",
|
|
2466
|
+
"message",
|
|
2467
|
+
"enforcement",
|
|
2468
|
+
"source_revision",
|
|
2469
|
+
"prefilter",
|
|
2470
|
+
"max_bytes",
|
|
2471
|
+
"bypass_limit",
|
|
2472
|
+
"set_headers",
|
|
2473
|
+
"remove_headers",
|
|
2474
|
+
"cookie_flags",
|
|
2475
|
+
"ensure"
|
|
2476
|
+
]);
|
|
2477
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
2478
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
2479
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2480
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
2481
|
+
"set-header": Object.freeze({
|
|
2482
|
+
required: Object.freeze(["set_headers"]),
|
|
2483
|
+
defaulted: Object.freeze(["ensure"]),
|
|
2484
|
+
phases: Object.freeze(["response"]),
|
|
2485
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
2486
|
+
}),
|
|
2487
|
+
"remove-header": Object.freeze({
|
|
2488
|
+
required: Object.freeze(["remove_headers"]),
|
|
2489
|
+
defaulted: Object.freeze([]),
|
|
2490
|
+
phases: Object.freeze(["response"]),
|
|
2491
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
2492
|
+
}),
|
|
2493
|
+
"harden-cookie": Object.freeze({
|
|
2494
|
+
required: Object.freeze([]),
|
|
2495
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
2496
|
+
phases: Object.freeze(["response"]),
|
|
2497
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
2498
|
+
})
|
|
2499
|
+
});
|
|
2500
|
+
var LIMITS = Object.freeze({
|
|
2111
2501
|
maxRules: 5e3,
|
|
2112
2502
|
maxWhitelists: 2e3,
|
|
2113
2503
|
maxConditionsPerRule: 250,
|
|
2114
2504
|
maxNestingDepth: 12,
|
|
2115
2505
|
maxRegexLength: 1e3,
|
|
2116
2506
|
maxValueLength: 8192
|
|
2117
|
-
};
|
|
2118
|
-
var
|
|
2119
|
-
|
|
2507
|
+
});
|
|
2508
|
+
var GROUP_PARAMETER = "rules";
|
|
2509
|
+
function isGroup(condition) {
|
|
2510
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
2511
|
+
}
|
|
2512
|
+
function conditionShapeProblem(condition) {
|
|
2513
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
2514
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
2515
|
+
const nested = Array.isArray(condition.rules);
|
|
2516
|
+
if (named && !nested) {
|
|
2517
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
2518
|
+
}
|
|
2519
|
+
if (nested && !named) {
|
|
2520
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2521
|
+
}
|
|
2522
|
+
if (named && nested) {
|
|
2523
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2524
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
2525
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
2526
|
+
}
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
function parameterProblem(parameter) {
|
|
2530
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
2531
|
+
if (Array.isArray(parameter)) {
|
|
2532
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
2533
|
+
for (const member of parameter) {
|
|
2534
|
+
const problem = parameterProblem(member);
|
|
2535
|
+
if (problem !== null) return problem;
|
|
2536
|
+
}
|
|
2537
|
+
return null;
|
|
2538
|
+
}
|
|
2539
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
2540
|
+
if (parameter === GROUP_PARAMETER) {
|
|
2541
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
2542
|
+
}
|
|
2543
|
+
const dot = parameter.indexOf(".");
|
|
2544
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
2545
|
+
const source = SOURCES[name];
|
|
2546
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
2547
|
+
if (dot === -1) {
|
|
2548
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
2549
|
+
}
|
|
2550
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
2551
|
+
const key = parameter.slice(dot + 1);
|
|
2552
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
2553
|
+
return keyProblem(name, source, key);
|
|
2554
|
+
}
|
|
2555
|
+
function keyProblem(name, source, key) {
|
|
2556
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
2557
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
2558
|
+
}
|
|
2559
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
2560
|
+
if (source.keys === "any") return null;
|
|
2561
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
2562
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
2563
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
2564
|
+
}
|
|
2565
|
+
const accepted = [
|
|
2566
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
2567
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
2568
|
+
].join(", ");
|
|
2569
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
2570
|
+
}
|
|
2571
|
+
function matchProblem(match, parameter) {
|
|
2572
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
2573
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
2574
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
2575
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
2576
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
2577
|
+
return `match type "${match.type}" needs a parameter`;
|
|
2578
|
+
}
|
|
2579
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
2580
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
2581
|
+
}
|
|
2582
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
2583
|
+
if (operands) {
|
|
2584
|
+
for (const required of operands.required) {
|
|
2585
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
2586
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
2590
|
+
if (match[operand] === void 0) continue;
|
|
2591
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
2592
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
2593
|
+
}
|
|
2594
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
2595
|
+
return `match type "${match.type}" needs a value`;
|
|
2596
|
+
}
|
|
2597
|
+
return null;
|
|
2598
|
+
}
|
|
2599
|
+
function mutationsProblem(mutations) {
|
|
2600
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
2601
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
2602
|
+
for (const mutation of mutations) {
|
|
2603
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
2604
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
function whenProblem(when) {
|
|
2610
|
+
if (when === void 0 || when === null) return null;
|
|
2611
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
2612
|
+
const keys = Object.keys(when);
|
|
2613
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
2614
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
2615
|
+
if (unknown.length > 0) {
|
|
2616
|
+
return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
|
|
2617
|
+
}
|
|
2618
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
2619
|
+
return "when.path must be a non-empty string";
|
|
2620
|
+
}
|
|
2621
|
+
if ("method" in when) {
|
|
2622
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
2623
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
2624
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
2625
|
+
}
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
function isNonEmptyString(value) {
|
|
2629
|
+
return typeof value === "string" && value.trim() !== "";
|
|
2630
|
+
}
|
|
2631
|
+
function nullPropertyProblem(rule) {
|
|
2632
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2633
|
+
for (const property of RULE_PROPERTIES) {
|
|
2634
|
+
if (property in rule && rule[property] === null) {
|
|
2635
|
+
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`;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
return null;
|
|
2639
|
+
}
|
|
2640
|
+
function rulePropertyProblem(rule) {
|
|
2641
|
+
if (!rule || typeof rule !== "object") return null;
|
|
2642
|
+
for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
|
|
2643
|
+
if (rule[property] === void 0 || rule[property] === null) continue;
|
|
2644
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2645
|
+
if (problem) return `"${property}" ${problem}`;
|
|
2646
|
+
}
|
|
2647
|
+
return null;
|
|
2648
|
+
}
|
|
2649
|
+
function actionProblem(action, rule) {
|
|
2650
|
+
if (action === void 0 || action === null) return null;
|
|
2651
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
2652
|
+
const spec = ACTION_PROPERTIES[action];
|
|
2653
|
+
for (const required of spec?.required ?? []) {
|
|
2654
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
2655
|
+
return `action "${action}" needs "${required}"`;
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
2659
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
2660
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
2661
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
2662
|
+
}
|
|
2663
|
+
const phase = rule?.phase ?? "request";
|
|
2664
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
2665
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
2666
|
+
}
|
|
2667
|
+
return null;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// src/protect/rules/validate.js
|
|
2671
|
+
var LIMITS2 = LIMITS;
|
|
2672
|
+
var PHASES2 = new Set(PHASES);
|
|
2673
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
2120
2674
|
function validateBundle(bundle, opts = {}) {
|
|
2121
2675
|
const rejected = [];
|
|
2122
2676
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2123
2677
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2124
2678
|
const firewall = [];
|
|
2125
2679
|
for (const rule of inFirewall) {
|
|
2126
|
-
if (firewall.length >=
|
|
2127
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
2680
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
2681
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
2128
2682
|
continue;
|
|
2129
2683
|
}
|
|
2130
2684
|
const reason = ruleProblem(rule);
|
|
@@ -2133,8 +2687,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
2133
2687
|
}
|
|
2134
2688
|
const whitelists = [];
|
|
2135
2689
|
for (const wl of inWhitelists) {
|
|
2136
|
-
if (whitelists.length >=
|
|
2137
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
2690
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
2691
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
2138
2692
|
continue;
|
|
2139
2693
|
}
|
|
2140
2694
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -2156,37 +2710,47 @@ function idOf(rule) {
|
|
|
2156
2710
|
}
|
|
2157
2711
|
function ruleProblem(rule) {
|
|
2158
2712
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
2159
|
-
|
|
2160
|
-
if (
|
|
2161
|
-
|
|
2162
|
-
|
|
2713
|
+
const nullReason = nullPropertyProblem(rule);
|
|
2714
|
+
if (nullReason) return nullReason;
|
|
2715
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2716
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
2717
|
+
if (actionReason) return actionReason;
|
|
2718
|
+
const scopeReason = whenProblem(rule.when);
|
|
2719
|
+
if (scopeReason) return scopeReason;
|
|
2720
|
+
const propertyReason = rulePropertyProblem(rule);
|
|
2721
|
+
if (propertyReason) return propertyReason;
|
|
2163
2722
|
return conditionsProblem(rule.rule_v2);
|
|
2164
2723
|
}
|
|
2165
2724
|
function conditionsProblem(conditions, depth = 0) {
|
|
2166
2725
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2167
2726
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2168
|
-
if (depth >
|
|
2169
|
-
if (conditions.length >
|
|
2170
|
-
return `more than ${
|
|
2727
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
2728
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
2729
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
2171
2730
|
}
|
|
2172
2731
|
for (const c of conditions) {
|
|
2173
|
-
|
|
2174
|
-
if (
|
|
2732
|
+
const shapeReason = conditionShapeProblem(c);
|
|
2733
|
+
if (shapeReason) return shapeReason;
|
|
2734
|
+
if (isGroup(c)) {
|
|
2175
2735
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2176
2736
|
if (nested) return nested;
|
|
2177
2737
|
continue;
|
|
2178
2738
|
}
|
|
2739
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
2740
|
+
if (parameterReason) return parameterReason;
|
|
2741
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
2742
|
+
if (mutationReason) return mutationReason;
|
|
2743
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
2744
|
+
if (matchReason) return matchReason;
|
|
2179
2745
|
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
2746
|
if (m.type === "regex") {
|
|
2183
2747
|
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 ${
|
|
2748
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
2749
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
2750
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
2187
2751
|
}
|
|
2188
2752
|
if (m.match) {
|
|
2189
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2753
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
2190
2754
|
if (nested) return nested;
|
|
2191
2755
|
}
|
|
2192
2756
|
}
|
|
@@ -2214,64 +2778,67 @@ function reportRejections(rejected, options, label) {
|
|
|
2214
2778
|
`${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
2779
|
), "onError");
|
|
2216
2780
|
}
|
|
2781
|
+
function fromSource(bundle, reason) {
|
|
2782
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
2783
|
+
}
|
|
2217
2784
|
async function resolveRules(options, store, ctx = {}) {
|
|
2218
2785
|
const timeoutMs = ctx.timeoutMs;
|
|
2219
2786
|
if (options.siteUuid) {
|
|
2220
2787
|
const prior = await store.read();
|
|
2221
2788
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2222
2789
|
const res = await client.getRules();
|
|
2223
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2790
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2224
2791
|
if (res.success && !res.notModified) {
|
|
2225
2792
|
const rejected = liveUpdateRejections(res, options);
|
|
2226
2793
|
if (rejected.length > 0) {
|
|
2227
2794
|
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();
|
|
2795
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2796
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2797
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2231
2798
|
}
|
|
2232
2799
|
const bundle = normalizeBundle(res, options);
|
|
2233
2800
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2234
|
-
return bundle;
|
|
2801
|
+
return fromSource(bundle);
|
|
2235
2802
|
}
|
|
2236
2803
|
if (prior?.bundle) {
|
|
2237
2804
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2238
|
-
return normalizeBundle(prior.bundle, options);
|
|
2805
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2239
2806
|
}
|
|
2240
2807
|
if (options.rules) {
|
|
2241
2808
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2242
|
-
return normalizeBundle(options.rules, options);
|
|
2809
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
2243
2810
|
}
|
|
2244
2811
|
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();
|
|
2812
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2246
2813
|
}
|
|
2247
2814
|
if (options.token) {
|
|
2248
2815
|
const prior = await store.read();
|
|
2249
2816
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2250
2817
|
const res = await client.getRules();
|
|
2251
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2818
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
2252
2819
|
if (res.success && !res.notModified) {
|
|
2253
2820
|
const rejected = liveUpdateRejections(res, options);
|
|
2254
2821
|
if (rejected.length > 0) {
|
|
2255
2822
|
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();
|
|
2823
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
2824
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
2825
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
2259
2826
|
}
|
|
2260
2827
|
const bundle = normalizeBundle(res, options);
|
|
2261
2828
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2262
|
-
return bundle;
|
|
2829
|
+
return fromSource(bundle);
|
|
2263
2830
|
}
|
|
2264
2831
|
if (prior?.bundle) {
|
|
2265
2832
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2266
|
-
return normalizeBundle(prior.bundle, options);
|
|
2833
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
2267
2834
|
}
|
|
2268
2835
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2269
|
-
return emptyBundle();
|
|
2836
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
2270
2837
|
}
|
|
2271
2838
|
if (options.rules) {
|
|
2272
|
-
return normalizeBundle(options.rules, options);
|
|
2839
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
2273
2840
|
}
|
|
2274
|
-
return emptyBundle();
|
|
2841
|
+
return fromSource(emptyBundle());
|
|
2275
2842
|
}
|
|
2276
2843
|
function normalizeBundle(b, options = {}) {
|
|
2277
2844
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -2324,8 +2891,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2324
2891
|
const run = async () => {
|
|
2325
2892
|
if (stopped) return;
|
|
2326
2893
|
try {
|
|
2327
|
-
await tick();
|
|
2328
|
-
|
|
2894
|
+
const status = await tick();
|
|
2895
|
+
if (status && status.ok === false) failures++;
|
|
2896
|
+
else failures = 0;
|
|
2329
2897
|
} catch (err) {
|
|
2330
2898
|
failures++;
|
|
2331
2899
|
notify(onError, err, "onError");
|
|
@@ -2352,7 +2920,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2352
2920
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
2353
2921
|
let refreshed = true;
|
|
2354
2922
|
try {
|
|
2355
|
-
await tick();
|
|
2923
|
+
const status = await tick();
|
|
2924
|
+
if (status && status.ok === false) refreshed = false;
|
|
2356
2925
|
} catch {
|
|
2357
2926
|
refreshed = false;
|
|
2358
2927
|
}
|
|
@@ -2380,6 +2949,12 @@ function ruleParameters(rule) {
|
|
|
2380
2949
|
walk(rule?.rule_v2);
|
|
2381
2950
|
return [...out];
|
|
2382
2951
|
}
|
|
2952
|
+
function revisionOf(rule) {
|
|
2953
|
+
const revision = rule?.source_revision;
|
|
2954
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
2955
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2383
2958
|
function routeOf(path) {
|
|
2384
2959
|
if (typeof path !== "string" || path === "") return null;
|
|
2385
2960
|
const cut = path.search(/[?#]/);
|
|
@@ -2388,16 +2963,30 @@ function routeOf(path) {
|
|
|
2388
2963
|
function createDetectionReporter(opts) {
|
|
2389
2964
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2390
2965
|
if (!siteUuid) {
|
|
2391
|
-
return {
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2966
|
+
return {
|
|
2967
|
+
record() {
|
|
2968
|
+
},
|
|
2969
|
+
flush() {
|
|
2970
|
+
},
|
|
2971
|
+
stop() {
|
|
2972
|
+
},
|
|
2973
|
+
setRulesEtag() {
|
|
2974
|
+
},
|
|
2975
|
+
dropped: () => 0,
|
|
2976
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
2977
|
+
};
|
|
2395
2978
|
}
|
|
2396
2979
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2397
2980
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2398
2981
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2399
2982
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2400
2983
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
2984
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
2985
|
+
let sent = 0;
|
|
2986
|
+
let delivered = 0;
|
|
2987
|
+
let failed = 0;
|
|
2988
|
+
let droppedTotal = 0;
|
|
2989
|
+
let lastDeliveredAt = null;
|
|
2401
2990
|
let queue = [];
|
|
2402
2991
|
let timer = null;
|
|
2403
2992
|
let stopped = false;
|
|
@@ -2411,6 +3000,8 @@ function createDetectionReporter(opts) {
|
|
|
2411
3000
|
const batch = queue.splice(0, MAX_BATCH);
|
|
2412
3001
|
const droppedWith = dropped;
|
|
2413
3002
|
dropped = 0;
|
|
3003
|
+
droppedTotal += droppedWith;
|
|
3004
|
+
sent += batch.length;
|
|
2414
3005
|
void (async () => {
|
|
2415
3006
|
try {
|
|
2416
3007
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -2419,15 +3010,22 @@ function createDetectionReporter(opts) {
|
|
|
2419
3010
|
"Content-Type": "application/json",
|
|
2420
3011
|
Accept: "application/json",
|
|
2421
3012
|
"User-Agent": "@patchstack/connect",
|
|
2422
|
-
// Same credential path as the rules fetch
|
|
2423
|
-
//
|
|
3013
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
3014
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
3015
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
3016
|
+
// posting into a 401.
|
|
2424
3017
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2425
3018
|
},
|
|
2426
3019
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2427
3020
|
});
|
|
2428
|
-
if (res &&
|
|
2429
|
-
|
|
3021
|
+
if (res && res.ok) {
|
|
3022
|
+
delivered += batch.length;
|
|
3023
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3024
|
+
} else {
|
|
3025
|
+
failed += batch.length;
|
|
3026
|
+
}
|
|
2430
3027
|
} catch {
|
|
3028
|
+
failed += batch.length;
|
|
2431
3029
|
}
|
|
2432
3030
|
})();
|
|
2433
3031
|
};
|
|
@@ -2456,7 +3054,12 @@ function createDetectionReporter(opts) {
|
|
|
2456
3054
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2457
3055
|
// saw traffic it would have stopped.
|
|
2458
3056
|
enforced: detection.mode === "block",
|
|
2459
|
-
rules_etag:
|
|
3057
|
+
rules_etag: rulesEtag,
|
|
3058
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
3059
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
3060
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
3061
|
+
// bundle carried none.
|
|
3062
|
+
rule_revision: revisionOf(detection.rule),
|
|
2460
3063
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2461
3064
|
});
|
|
2462
3065
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -2470,7 +3073,24 @@ function createDetectionReporter(opts) {
|
|
|
2470
3073
|
stopped = true;
|
|
2471
3074
|
flush();
|
|
2472
3075
|
},
|
|
2473
|
-
|
|
3076
|
+
/**
|
|
3077
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
3078
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
3079
|
+
*
|
|
3080
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
3081
|
+
* the only moment the two are known to agree.
|
|
3082
|
+
*
|
|
3083
|
+
* @param {string | null | undefined} next
|
|
3084
|
+
*/
|
|
3085
|
+
setRulesEtag(next) {
|
|
3086
|
+
rulesEtag = next ?? null;
|
|
3087
|
+
},
|
|
3088
|
+
dropped: () => dropped,
|
|
3089
|
+
/**
|
|
3090
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
3091
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
3092
|
+
*/
|
|
3093
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
2474
3094
|
};
|
|
2475
3095
|
}
|
|
2476
3096
|
|
|
@@ -2735,22 +3355,32 @@ async function createProtection(options = {}) {
|
|
|
2735
3355
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2736
3356
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2737
3357
|
if (options.siteUuid && !pulseAuth) {
|
|
2738
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
3358
|
+
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
3359
|
notify(onError, new Error(message), "onError");
|
|
2740
3360
|
console.warn(message);
|
|
2741
3361
|
}
|
|
2742
3362
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
3363
|
+
let detectionReporting = "off";
|
|
2743
3364
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
3365
|
+
if (!pulseAuth) {
|
|
3366
|
+
detectionReporting = "unavailable-no-credential";
|
|
3367
|
+
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.";
|
|
3368
|
+
notify(onError, new Error(message), "onError");
|
|
3369
|
+
console.warn(message);
|
|
3370
|
+
} else {
|
|
3371
|
+
detectionReporting = "on";
|
|
3372
|
+
detections = createDetectionReporter({
|
|
3373
|
+
siteUuid: options.siteUuid,
|
|
3374
|
+
baseUrl: options.pulseRulesUrl,
|
|
3375
|
+
pulseAuth,
|
|
3376
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
3377
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
3378
|
+
// see the refresh tick below.
|
|
3379
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
3380
|
+
fetchImpl: options.fetchImpl,
|
|
3381
|
+
flushMs: options.detectionFlushMs
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
2754
3384
|
}
|
|
2755
3385
|
let mode = resolveMode(options, bundle);
|
|
2756
3386
|
let requestRules;
|
|
@@ -3084,11 +3714,17 @@ async function createProtection(options = {}) {
|
|
|
3084
3714
|
);
|
|
3085
3715
|
};
|
|
3086
3716
|
},
|
|
3087
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
3717
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
3718
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
3719
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
3088
3720
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
3089
3721
|
node(nodeOptions = {}) {
|
|
3090
3722
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
3091
3723
|
return (req, res, next) => {
|
|
3724
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
3725
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
3726
|
+
return;
|
|
3727
|
+
}
|
|
3092
3728
|
const chunks = [];
|
|
3093
3729
|
let size = 0;
|
|
3094
3730
|
let overflow = false;
|
|
@@ -3106,38 +3742,41 @@ async function createProtection(options = {}) {
|
|
|
3106
3742
|
});
|
|
3107
3743
|
req.on("end", () => {
|
|
3108
3744
|
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
|
-
);
|
|
3745
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
3139
3746
|
});
|
|
3140
3747
|
};
|
|
3748
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
3749
|
+
let shaped;
|
|
3750
|
+
let result;
|
|
3751
|
+
try {
|
|
3752
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
3753
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
3754
|
+
result = engine.evaluate(shaped);
|
|
3755
|
+
} catch (err) {
|
|
3756
|
+
notify(onError, err, "onError");
|
|
3757
|
+
return next();
|
|
3758
|
+
}
|
|
3759
|
+
decide(
|
|
3760
|
+
"request",
|
|
3761
|
+
result,
|
|
3762
|
+
() => {
|
|
3763
|
+
res.statusCode = 403;
|
|
3764
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
3765
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
3766
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
3767
|
+
} else {
|
|
3768
|
+
res.setHeader("content-type", "application/json");
|
|
3769
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
3770
|
+
}
|
|
3771
|
+
},
|
|
3772
|
+
() => {
|
|
3773
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
3774
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3775
|
+
next();
|
|
3776
|
+
},
|
|
3777
|
+
nodeRequestMeta(req)
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3141
3780
|
}
|
|
3142
3781
|
};
|
|
3143
3782
|
if (options.egress) {
|
|
@@ -3158,7 +3797,7 @@ async function createProtection(options = {}) {
|
|
|
3158
3797
|
let reporter = null;
|
|
3159
3798
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
3160
3799
|
try {
|
|
3161
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
3800
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-KNZQBC4V.js"));
|
|
3162
3801
|
} catch (err) {
|
|
3163
3802
|
notify(onError, err, "onError");
|
|
3164
3803
|
}
|
|
@@ -3174,24 +3813,22 @@ async function createProtection(options = {}) {
|
|
|
3174
3813
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3175
3814
|
mode = resolveMode(options, next);
|
|
3176
3815
|
applyBundle(next);
|
|
3816
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
3817
|
+
return next.source ?? { ok: true };
|
|
3177
3818
|
};
|
|
3178
3819
|
if (live) {
|
|
3179
3820
|
protection.refresh = () => runRefreshTick();
|
|
3180
3821
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
3181
3822
|
}
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
firewallLog.stop();
|
|
3192
|
-
detections?.stop();
|
|
3193
|
-
};
|
|
3194
|
-
}
|
|
3823
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
3824
|
+
protection.stop = () => {
|
|
3825
|
+
loop?.stop();
|
|
3826
|
+
firewallLog?.stop();
|
|
3827
|
+
detections?.stop();
|
|
3828
|
+
};
|
|
3829
|
+
protection.stopRefresh = protection.stop;
|
|
3830
|
+
protection.detectionReporting = detectionReporting;
|
|
3831
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
3195
3832
|
return protection;
|
|
3196
3833
|
}
|
|
3197
3834
|
function resolveMode(options, bundle) {
|
|
@@ -3202,22 +3839,35 @@ function resolveMode(options, bundle) {
|
|
|
3202
3839
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3203
3840
|
return "dry-run";
|
|
3204
3841
|
}
|
|
3842
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
3843
|
+
async function readCredentialField(options, field) {
|
|
3844
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3845
|
+
let readFileSync;
|
|
3846
|
+
let join;
|
|
3847
|
+
try {
|
|
3848
|
+
const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
|
|
3849
|
+
readFileSync = fs.readFileSync;
|
|
3850
|
+
join = pathMod.join;
|
|
3851
|
+
} catch {
|
|
3852
|
+
return void 0;
|
|
3853
|
+
}
|
|
3854
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3855
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
3856
|
+
try {
|
|
3857
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
3858
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3859
|
+
} catch {
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
return void 0;
|
|
3863
|
+
}
|
|
3205
3864
|
async function resolveApiKey(options) {
|
|
3206
3865
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3207
3866
|
if (typeof process !== "undefined") {
|
|
3208
3867
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3209
3868
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3210
3869
|
}
|
|
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;
|
|
3870
|
+
return readCredentialField(options, "apiKey");
|
|
3221
3871
|
}
|
|
3222
3872
|
async function resolvePulseAuth(options) {
|
|
3223
3873
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -3225,15 +3875,8 @@ async function resolvePulseAuth(options) {
|
|
|
3225
3875
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3226
3876
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3227
3877
|
}
|
|
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
|
-
}
|
|
3878
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
3879
|
+
if (fromFile !== void 0) return fromFile;
|
|
3237
3880
|
return resolveApiKey(options);
|
|
3238
3881
|
}
|
|
3239
3882
|
function byPhase(rules, phase) {
|
|
@@ -3366,6 +4009,8 @@ function extractRedactors(rule) {
|
|
|
3366
4009
|
}
|
|
3367
4010
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
3368
4011
|
out.push({ literal: String(m.value) });
|
|
4012
|
+
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
4013
|
+
out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
|
|
3369
4014
|
} else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
|
|
3370
4015
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
3371
4016
|
for (const key of keys) {
|
|
@@ -3384,7 +4029,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
3384
4029
|
let out = body;
|
|
3385
4030
|
for (const r of redactors) {
|
|
3386
4031
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
3387
|
-
else if (r.
|
|
4032
|
+
else if (r.jwtClaim) {
|
|
4033
|
+
for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
|
|
4034
|
+
out = out.split(token).join(transform ? transform(token) : mask);
|
|
4035
|
+
}
|
|
4036
|
+
} else if (r.literal) {
|
|
3388
4037
|
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
3389
4038
|
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
3390
4039
|
}
|
|
@@ -3518,11 +4167,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
3518
4167
|
}
|
|
3519
4168
|
}
|
|
3520
4169
|
}
|
|
4170
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
3521
4171
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3522
4172
|
let out = String(cookie);
|
|
3523
4173
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3524
4174
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3525
|
-
|
|
4175
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
4176
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
3526
4177
|
return out;
|
|
3527
4178
|
}
|
|
3528
4179
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -3536,11 +4187,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3536
4187
|
} catch {
|
|
3537
4188
|
}
|
|
3538
4189
|
} else if (typeof value === "string") {
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
} catch {
|
|
3543
|
-
}
|
|
4190
|
+
try {
|
|
4191
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
4192
|
+
} catch {
|
|
3544
4193
|
}
|
|
3545
4194
|
} else if (Array.isArray(value)) {
|
|
3546
4195
|
try {
|