analytics-platform 0.2.1 → 0.2.2
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/README.md +1 -1
- package/index.js +6 -2
- package/lib/bans.js +16 -4
- package/lib/core.js +1 -1
- package/lib/crawlers.js +6 -1
- package/lib/errors.js +19 -6
- package/lib/firewall.js +3 -3
- package/lib/flags.js +1 -1
- package/lib/maintenance.js +18 -4
- package/lib/next.js +3 -1
- package/lib/performance.js +6 -1
- package/lib/posture.js +2 -0
- package/lib/proxy.js +10 -3
- package/lib/requestRules.js +88 -16
- package/lib/resources.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ createPlatform({
|
|
|
57
57
|
});
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
**Switch modules off in the dashboard** (Security →
|
|
60
|
+
**Switch modules off in the dashboard** (Security → Server package) without a
|
|
61
61
|
deploy: the package checks about once a minute. A module turned off in your
|
|
62
62
|
code isn't loaded at all, so the dashboard can't turn it on.
|
|
63
63
|
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"use strict";
|
|
15
15
|
|
|
16
16
|
const { createCore } = require("./lib/core.js");
|
|
17
|
+
const { clientIp } = require("./lib/firewall.js");
|
|
17
18
|
|
|
18
19
|
// Module name -> [loader, on by default].
|
|
19
20
|
const MODULES = {
|
|
@@ -46,8 +47,11 @@ function createPlatform(options = {}) {
|
|
|
46
47
|
const setting = options[name] === undefined ? onByDefault : options[name];
|
|
47
48
|
if (setting === false) continue;
|
|
48
49
|
const opts = { trustProxy: options.trustProxy, ...(setting && typeof setting === "object" ? setting : {}) };
|
|
49
|
-
//
|
|
50
|
-
|
|
50
|
+
// These find the visitor's address the way the firewall does, with the
|
|
51
|
+
// same trustProxy, whether or not the firewall itself is on.
|
|
52
|
+
if (["forms", "audit", "maintenance", "crawlers", "proxy"].includes(name)) {
|
|
53
|
+
opts.clientIp = modules.firewall?.clientIp || ((req) => clientIp(req, options.trustProxy));
|
|
54
|
+
}
|
|
51
55
|
try {
|
|
52
56
|
modules[name] = load(core, opts);
|
|
53
57
|
} catch (error) {
|
package/lib/bans.js
CHANGED
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
// of files no visitor asks for (.env, .git/...). No Node built-ins (edge-safe).
|
|
6
6
|
"use strict";
|
|
7
7
|
|
|
8
|
+
const { parseIp } = require("./ip.js");
|
|
9
|
+
|
|
8
10
|
const MAX = 50000;
|
|
9
11
|
|
|
12
|
+
// One key per address however it's spelled: "1.2.3.4" and "::ffff:1.2.3.4"
|
|
13
|
+
// (and IPv6 written two ways) are the same visitor.
|
|
14
|
+
function keyOf(ip) {
|
|
15
|
+
const address = parseIp(ip);
|
|
16
|
+
return address ? `${address.family}:${address.n}` : String(ip);
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
function createLocalBans() {
|
|
11
20
|
const bans = new Map(); // address -> when the ban ends (ms)
|
|
12
21
|
const logins = new Map(); // address -> { start, count }
|
|
13
22
|
|
|
14
|
-
function banned(
|
|
23
|
+
function banned(address) {
|
|
24
|
+
const ip = keyOf(address);
|
|
15
25
|
const until = bans.get(ip);
|
|
16
26
|
if (!until) return false;
|
|
17
27
|
if (until > Date.now()) return true;
|
|
@@ -19,16 +29,18 @@ function createLocalBans() {
|
|
|
19
29
|
return false;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
|
-
function ban(
|
|
32
|
+
function ban(address, seconds) {
|
|
23
33
|
if (!(seconds > 0)) return;
|
|
34
|
+
const ip = keyOf(address);
|
|
24
35
|
if (bans.size >= MAX) bans.clear();
|
|
25
36
|
bans.set(ip, Math.max(bans.get(ip) || 0, Date.now() + seconds * 1000));
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
// One failed login; login: the rules' { limit, window, ban } (seconds).
|
|
29
40
|
// True when it got the address banned.
|
|
30
|
-
function loginFailed(
|
|
41
|
+
function loginFailed(address, login) {
|
|
31
42
|
if (!login) return false;
|
|
43
|
+
const ip = keyOf(address);
|
|
32
44
|
const now = Date.now();
|
|
33
45
|
let entry = logins.get(ip);
|
|
34
46
|
if (!entry || now - entry.start >= login.window * 1000) {
|
|
@@ -38,7 +50,7 @@ function createLocalBans() {
|
|
|
38
50
|
}
|
|
39
51
|
entry.count += 1;
|
|
40
52
|
if (entry.count < login.limit) return false;
|
|
41
|
-
ban(
|
|
53
|
+
ban(address, login.ban);
|
|
42
54
|
logins.delete(ip);
|
|
43
55
|
return true;
|
|
44
56
|
}
|
package/lib/core.js
CHANGED
package/lib/crawlers.js
CHANGED
|
@@ -117,6 +117,7 @@ function createCrawlers(core, options = {}) {
|
|
|
117
117
|
? options.clientIp
|
|
118
118
|
: (req) => req.ip || req.socket?.remoteAddress || "";
|
|
119
119
|
let rows = new Map();
|
|
120
|
+
const keysIn = new Map(); // minute -> rows it has
|
|
120
121
|
let agents = new Map();
|
|
121
122
|
let sending = Promise.resolve();
|
|
122
123
|
|
|
@@ -127,11 +128,14 @@ function createCrawlers(core, options = {}) {
|
|
|
127
128
|
const clean = String(path || "/").split(/[?#]/)[0].slice(0, MAX_PATH) || "/";
|
|
128
129
|
let key = `${minute}|${bot}|${clean}|${status}`;
|
|
129
130
|
// Past MAX_KEYS a crawler asking for thousands of URLs becomes one row.
|
|
130
|
-
|
|
131
|
+
// The cap is per minute: rows of a minute not yet sent don't count.
|
|
132
|
+
const used = keysIn.get(minute) || 0;
|
|
133
|
+
if (!rows.has(key) && used >= MAX_KEYS) key = `${minute}|${bot}|${OTHER}|${status}`;
|
|
131
134
|
let row = rows.get(key);
|
|
132
135
|
if (!row) {
|
|
133
136
|
row = { minute, bot, path: key.split("|")[2], status, count: 0, bytes: 0, sum_ms: 0 };
|
|
134
137
|
rows.set(key, row);
|
|
138
|
+
keysIn.set(minute, used + 1);
|
|
135
139
|
}
|
|
136
140
|
row.count += 1;
|
|
137
141
|
row.bytes += Math.max(0, Number(bytes) || 0);
|
|
@@ -177,6 +181,7 @@ function createCrawlers(core, options = {}) {
|
|
|
177
181
|
rows.delete(key);
|
|
178
182
|
}
|
|
179
183
|
}
|
|
184
|
+
for (const minute of keysIn.keys()) if (all || minute < current) keysIn.delete(minute);
|
|
180
185
|
if (!ready.length) return sending;
|
|
181
186
|
const sent = new Set(ready.map((row) => row.bot));
|
|
182
187
|
const batchAgents = [...agents].filter(([bot]) => sent.has(bot)).map(([bot, ips]) => ({ bot, ips: [...ips] }));
|
package/lib/errors.js
CHANGED
|
@@ -13,6 +13,10 @@ const BATCH = 10;
|
|
|
13
13
|
const QUEUE_MAX = 100;
|
|
14
14
|
const FLUSH_MS = 2000;
|
|
15
15
|
const EXIT_FLUSH_MS = 2000;
|
|
16
|
+
// Every instance's uncaught-exception handler, with its flush: two platforms in
|
|
17
|
+
// one process are still "no other handler", and the exit waits for both.
|
|
18
|
+
const handlers = new Map();
|
|
19
|
+
let exiting = false;
|
|
16
20
|
|
|
17
21
|
// Options: serverName (default the host name), root (paths in stacks are
|
|
18
22
|
// made relative to it; default process.cwd()), release (which build this is,
|
|
@@ -140,16 +144,24 @@ function createErrors(core, options = {}) {
|
|
|
140
144
|
// after up to EXIT_FLUSH_MS to send it.
|
|
141
145
|
function onUncaught(error, origin) {
|
|
142
146
|
captureException(error, { kind: origin === "unhandledRejection" ? "rejection" : "error" });
|
|
143
|
-
if (process.
|
|
147
|
+
if (exiting || process.listeners("uncaughtException").some((listener) => !handlers.has(listener))) return;
|
|
148
|
+
exiting = true;
|
|
144
149
|
console.error(error);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
process.exit(1);
|
|
150
|
+
// After the other instances' handlers (called right after this one) have
|
|
151
|
+
// queued the error too.
|
|
152
|
+
process.nextTick(() => {
|
|
153
|
+
const exit = setTimeout(() => process.exit(1), EXIT_FLUSH_MS);
|
|
154
|
+
Promise.allSettled([...handlers.values()].map((send) => send())).finally(() => {
|
|
155
|
+
clearTimeout(exit);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
});
|
|
149
158
|
});
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
if (options.captureUncaught !== false)
|
|
161
|
+
if (options.captureUncaught !== false) {
|
|
162
|
+
handlers.set(onUncaught, flush);
|
|
163
|
+
process.on("uncaughtException", onUncaught);
|
|
164
|
+
}
|
|
153
165
|
|
|
154
166
|
return {
|
|
155
167
|
captureException,
|
|
@@ -157,6 +169,7 @@ function createErrors(core, options = {}) {
|
|
|
157
169
|
flush,
|
|
158
170
|
close() {
|
|
159
171
|
process.off("uncaughtException", onUncaught);
|
|
172
|
+
handlers.delete(onUncaught);
|
|
160
173
|
return flush();
|
|
161
174
|
},
|
|
162
175
|
};
|
package/lib/firewall.js
CHANGED
|
@@ -196,8 +196,8 @@ function createFirewall(core, options = {}) {
|
|
|
196
196
|
}
|
|
197
197
|
if (!rule && current.rate && !current.goodbots.has(address) && overRate(String(ip), current.rate)) {
|
|
198
198
|
rule = "rate";
|
|
199
|
-
// Banned here straight away; the report bans it on the
|
|
200
|
-
if (current.ban) local.ban(String(ip), current.ban.seconds);
|
|
199
|
+
// Banned here straight away in block mode, as a probe is; the report bans it on the other servers.
|
|
200
|
+
if (action === "block" && current.ban) local.ban(String(ip), current.ban.seconds);
|
|
201
201
|
}
|
|
202
202
|
if (!rule) return allow;
|
|
203
203
|
report({
|
|
@@ -259,4 +259,4 @@ function createFirewall(core, options = {}) {
|
|
|
259
259
|
return { middleware, check, loginFailed, flush, close, ready, clientIp: (req) => clientIp(req, options.trustProxy) };
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
module.exports = { createFirewall, parseIp, parseRange, IpSet };
|
|
262
|
+
module.exports = { createFirewall, clientIp, parseIp, parseRange, IpSet };
|
package/lib/flags.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Feature flags module: turn features of your app on or off from the
|
|
2
|
-
// dashboard (Security →
|
|
2
|
+
// dashboard (Security → Server package), for everyone, for a share of users, or
|
|
3
3
|
// for listed user IDs. isEnabled() answers from memory; the flags come with
|
|
4
4
|
// the module switches (core.js), about once a minute.
|
|
5
5
|
"use strict";
|
package/lib/maintenance.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Maintenance mode module: while it's on (Security →
|
|
1
|
+
// Maintenance mode module: while it's on (Security → Server package), visitors
|
|
2
2
|
// get a "back soon" page (503, so search engines know it's temporary),
|
|
3
3
|
// except allowed addresses and whoever opened the preview link
|
|
4
4
|
// (?site_preview=<code>, remembered for a day in a cookie).
|
|
@@ -21,11 +21,26 @@ function cookieOf(req, name) {
|
|
|
21
21
|
const header = String(req.headers?.cookie || "");
|
|
22
22
|
for (const part of header.split(";")) {
|
|
23
23
|
const [k, ...v] = part.trim().split("=");
|
|
24
|
-
if (k
|
|
24
|
+
if (k !== name) continue;
|
|
25
|
+
// A malformed value can't be the preview code, and mustn't throw: the
|
|
26
|
+
// middleware lets a request through when its check fails.
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(v.join("="));
|
|
29
|
+
} catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
25
32
|
}
|
|
26
33
|
return "";
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
function queryOf(req, name) {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(req.originalUrl || req.url || "/", "http://x").searchParams.get(name);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
// clientIp(req): the firewall's way of finding the visitor's address.
|
|
30
45
|
// Options: skip(req) (true lets a request through, e.g. health checks).
|
|
31
46
|
function createMaintenance(core, { clientIp, skip } = {}) {
|
|
@@ -43,8 +58,7 @@ function createMaintenance(core, { clientIp, skip } = {}) {
|
|
|
43
58
|
const ip = parseIp(clientIp ? clientIp(req) : req.ip || req.socket?.remoteAddress || "");
|
|
44
59
|
if (ip && allowSet.has(ip)) return next();
|
|
45
60
|
if (m.bypass) {
|
|
46
|
-
|
|
47
|
-
if (url.searchParams.get(COOKIE) === m.bypass) {
|
|
61
|
+
if (queryOf(req, COOKIE) === m.bypass) {
|
|
48
62
|
res.setHeader("set-cookie", `${COOKIE}=${encodeURIComponent(m.bypass)}; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax`);
|
|
49
63
|
return next();
|
|
50
64
|
}
|
package/lib/next.js
CHANGED
|
@@ -55,6 +55,8 @@ function compile(data) {
|
|
|
55
55
|
mode: data.mode === "block" ? "block" : "monitor",
|
|
56
56
|
sync: Math.max(MIN_SYNC_S, Number(data.sync) || RETRY_S),
|
|
57
57
|
allow: new IpSet(data.allow),
|
|
58
|
+
// Crawlers the rate limit must not catch, as in the Node firewall.
|
|
59
|
+
goodbots: new IpSet(data.goodbots),
|
|
58
60
|
deny: new IpSet(data.deny),
|
|
59
61
|
list: new IpSet(data.lists),
|
|
60
62
|
shared: new IpSet(data.shared),
|
|
@@ -169,7 +171,7 @@ function createNextFirewall(options = {}) {
|
|
|
169
171
|
if (effective === "block" && matched.ban) local.ban(ip, matched.ban);
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
|
-
if (!rule && current.rate && await overRate(ip, current.rate)) {
|
|
174
|
+
if (!rule && current.rate && !current.goodbots.has(address) && await overRate(ip, current.rate)) {
|
|
173
175
|
rule = "rate";
|
|
174
176
|
if (current.ban) local.ban(ip, current.ban.seconds);
|
|
175
177
|
}
|
package/lib/performance.js
CHANGED
|
@@ -40,6 +40,7 @@ function createPerformance(core, options = {}) {
|
|
|
40
40
|
const serverName = String(options.serverName ?? os.hostname()).slice(0, 100);
|
|
41
41
|
const runtime = `Node ${process.versions.node}`;
|
|
42
42
|
let rows = new Map();
|
|
43
|
+
const keysIn = new Map(); // minute -> rows it has
|
|
43
44
|
let sending = Promise.resolve();
|
|
44
45
|
|
|
45
46
|
// Records one finished request.
|
|
@@ -47,11 +48,14 @@ function createPerformance(core, options = {}) {
|
|
|
47
48
|
if (!core.isOn("performance")) return;
|
|
48
49
|
const minute = Math.floor(Date.now() / 60000) * 60000;
|
|
49
50
|
let key = `${minute}|${method}|${route}|${status}`;
|
|
50
|
-
|
|
51
|
+
// The cap is per minute: rows of a minute not yet sent don't count.
|
|
52
|
+
const used = keysIn.get(minute) || 0;
|
|
53
|
+
if (!rows.has(key) && used >= MAX_KEYS) key = `${minute}|${method}|(other)|${status}`;
|
|
51
54
|
let row = rows.get(key);
|
|
52
55
|
if (!row) {
|
|
53
56
|
row = { minute, method, route: key.split("|")[2], status, count: 0, sum_ms: 0, max_ms: 0, buckets: new Array(BUCKET_EDGES_MS.length + 1).fill(0) };
|
|
54
57
|
rows.set(key, row);
|
|
58
|
+
keysIn.set(minute, used + 1);
|
|
55
59
|
}
|
|
56
60
|
row.count += 1;
|
|
57
61
|
row.sum_ms += ms;
|
|
@@ -91,6 +95,7 @@ function createPerformance(core, options = {}) {
|
|
|
91
95
|
rows.delete(key);
|
|
92
96
|
}
|
|
93
97
|
}
|
|
98
|
+
for (const minute of keysIn.keys()) if (all || minute < current) keysIn.delete(minute);
|
|
94
99
|
for (let i = 0; i < ready.length; i += MAX_ROWS) {
|
|
95
100
|
const batch = ready.slice(i, i + MAX_ROWS);
|
|
96
101
|
sending = sending.then(() => core.send("performance", "/events/server-requests", { body: { server_name: serverName, runtime, rows: batch } }));
|
package/lib/posture.js
CHANGED
|
@@ -204,6 +204,8 @@ function createPosture(core, options = {}) {
|
|
|
204
204
|
|
|
205
205
|
function schedule(ms) {
|
|
206
206
|
if (closed) return;
|
|
207
|
+
// A report called by hand restarts the wait rather than starting a second chain.
|
|
208
|
+
clearTimeout(timer);
|
|
207
209
|
timer = setTimeout(report, ms);
|
|
208
210
|
timer.unref?.();
|
|
209
211
|
}
|
package/lib/proxy.js
CHANGED
|
@@ -23,7 +23,7 @@ const crypto = require("node:crypto");
|
|
|
23
23
|
|
|
24
24
|
// The tracker files, by name: anything else is refused rather than forwarded.
|
|
25
25
|
const FILES = new Set(["track.js", "s.js", "snapshot.js", "replay.js", "survey.js", "banner.js",
|
|
26
|
-
"leadform.js", "notice.js", "editor.js", "picker.js", "feedback.js", "shield.js"]);
|
|
26
|
+
"leadform.js", "notice.js", "editor.js", "picker.js", "feedback.js", "shield.js", "widget.js", "share.js"]);
|
|
27
27
|
// A pinned version never changes; /v<major>/ follows new releases, so it's
|
|
28
28
|
// held for an hour, the same as the collector serves it with.
|
|
29
29
|
const TTL_PINNED_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -34,6 +34,9 @@ const MAX_BODY = 6 * 1024 * 1024;
|
|
|
34
34
|
const PASS_HEADERS = ["content-type", "user-agent", "accept-language", "referer", "origin",
|
|
35
35
|
"sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"];
|
|
36
36
|
|
|
37
|
+
const isEmptyObject = (value) => value !== null && typeof value === "object" && !Buffer.isBuffer(value)
|
|
38
|
+
&& Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === 0;
|
|
39
|
+
|
|
37
40
|
const prefixFor = (secret) => crypto.createHash("sha256").update(`proxy|${secret}`).digest("hex").slice(0, 8);
|
|
38
41
|
|
|
39
42
|
// Options: prefix (default: from the secret), enabled (default true).
|
|
@@ -84,8 +87,12 @@ function createProxy(core, options = {}) {
|
|
|
84
87
|
|
|
85
88
|
// The request's body, however the app is put together: straight from the
|
|
86
89
|
// stream, or from whatever a body parser mounted before us already read.
|
|
90
|
+
// Express 4's parsers set req.body = {} even for a type they skip (the
|
|
91
|
+
// tracker's text/plain), leaving the stream unread: that empty object isn't
|
|
92
|
+
// the body. req._body is how they mark one they did read.
|
|
87
93
|
function readBody(req) {
|
|
88
|
-
|
|
94
|
+
const skipped = req._body !== true && isEmptyObject(req.body);
|
|
95
|
+
if (req.readableEnded || (req.body !== undefined && !skipped)) {
|
|
89
96
|
const parsed = req.body;
|
|
90
97
|
if (parsed === undefined || parsed === null) return Promise.resolve("");
|
|
91
98
|
if (typeof parsed === "string" || Buffer.isBuffer(parsed)) return Promise.resolve(parsed);
|
|
@@ -134,7 +141,7 @@ function createProxy(core, options = {}) {
|
|
|
134
141
|
const headers = {
|
|
135
142
|
// The visitor's address: the collector needs it for the country, the
|
|
136
143
|
// daily visitor ID and the per-visitor rate limit.
|
|
137
|
-
"x-forwarded-for": (clientIp ? clientIp(req) :
|
|
144
|
+
"x-forwarded-for": (clientIp ? clientIp(req) : req.ip) || req.socket?.remoteAddress || "",
|
|
138
145
|
"x-forwarded-proto": req.socket?.encrypted ? "https" : "http",
|
|
139
146
|
};
|
|
140
147
|
for (const name of PASS_HEADERS) {
|
package/lib/requestRules.js
CHANGED
|
@@ -20,25 +20,36 @@ const MAX_REGEX = 200;
|
|
|
20
20
|
const lower = (value) => String(value ?? "").slice(0, MAX_TEXT).toLowerCase();
|
|
21
21
|
// One pass only, as the protocol says: enough for %2e%2e, and no risk of
|
|
22
22
|
// decoding a visitor's own text into something that looks like an attack.
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Each run of escapes is decoded on its own, so a stray "%" elsewhere can't
|
|
24
|
+
// leave the whole text undecoded; a run that isn't valid UTF-8 still has its
|
|
25
|
+
// ASCII escapes (%2e, %2f) decoded.
|
|
26
|
+
function decodeRun(run) {
|
|
25
27
|
try {
|
|
26
|
-
return decodeURIComponent(
|
|
28
|
+
return decodeURIComponent(run);
|
|
27
29
|
} catch {
|
|
28
|
-
return
|
|
30
|
+
return run.replace(/%([0-7][0-9a-f])/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
function decodeOnce(text) {
|
|
34
|
+
if (!text.includes("%") && !text.includes("+")) return text;
|
|
35
|
+
return text.replace(/\+/g, " ").replace(/(?:%[0-9a-f]{2})+/gi, decodeRun);
|
|
36
|
+
}
|
|
31
37
|
const normalizePath = (path) => lower(path).replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
32
38
|
|
|
33
39
|
// A pattern can only come from us, but a careless one would be matched against
|
|
34
40
|
// text an attacker chooses, in this very request, and JavaScript cannot time a
|
|
35
41
|
// regex out. So the client checks the shape itself instead of trusting what it
|
|
36
|
-
// was handed: a
|
|
37
|
-
// exponential time on input that nearly matches.
|
|
42
|
+
// was handed: a repeated group that can match the same text more than one way
|
|
43
|
+
// takes exponential time on input that nearly matches. A group can do that when
|
|
44
|
+
// it holds a quantifier ("(a+)+", "(\w\d?)+") or an alternation ("(a|ab)*"),
|
|
45
|
+
// so a group followed by * + or { holds neither. Backreferences and lookbehind
|
|
38
46
|
// are refused for the same reason (and they aren't in every engine we ship to).
|
|
47
|
+
// The same check as backend/services/firewall/requestRules.js, freeRepeats
|
|
48
|
+
// included; sdk/protocols/request-rules-cases.json holds the shared lists.
|
|
39
49
|
function safePattern(pattern) {
|
|
40
50
|
if (typeof pattern !== "string" || !pattern || pattern.length > MAX_REGEX) return false;
|
|
41
51
|
if (/\\[1-9]/.test(pattern) || /\(\?</.test(pattern)) return false;
|
|
52
|
+
// Per open group: whether anything in it, nested groups too, repeats or branches.
|
|
42
53
|
const open = [];
|
|
43
54
|
for (let i = 0; i < pattern.length; i++) {
|
|
44
55
|
const char = pattern[i];
|
|
@@ -47,22 +58,82 @@ function safePattern(pattern) {
|
|
|
47
58
|
continue;
|
|
48
59
|
}
|
|
49
60
|
if (char === "[") {
|
|
50
|
-
// A character class: quantifiers inside it are literals.
|
|
61
|
+
// A character class: skip it whole, quantifiers inside it are literals.
|
|
51
62
|
while (i < pattern.length && pattern[i] !== "]") i += pattern[i] === "\\" ? 2 : 1;
|
|
52
63
|
continue;
|
|
53
64
|
}
|
|
54
|
-
if (char === "(")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
65
|
+
if (char === "(") {
|
|
66
|
+
open.push(false);
|
|
67
|
+
// "(?:", "(?=", "(?!": that ? names the kind of group, it repeats nothing.
|
|
68
|
+
if (pattern[i + 1] === "?") i += 2;
|
|
69
|
+
} else if (char === ")") {
|
|
70
|
+
if (!open.length) return false;
|
|
71
|
+
const ambiguous = open.pop();
|
|
58
72
|
const next = pattern[i + 1];
|
|
59
|
-
if (next === "*" || next === "+" || next === "{")
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
if (ambiguous && (next === "*" || next === "+" || next === "{")) return false;
|
|
74
|
+
if (ambiguous && open.length) open[open.length - 1] = true;
|
|
75
|
+
} else if (open.length && "|*+?{".includes(char)) {
|
|
76
|
+
open[open.length - 1] = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return open.length === 0 && freeRepeats(pattern) <= 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Two repeats that can both take the same text multiply each other's work:
|
|
83
|
+
// ".*=.*=.*x" against a query of "=" that never ends in "x" is polynomial,
|
|
84
|
+
// and the 2000 characters the clients match at most is plenty for that to
|
|
85
|
+
// hang a request. A repeat is pinned when the character straight after it is
|
|
86
|
+
// one it can't match ("[^/]+/" ends at the first "/", once); any other is
|
|
87
|
+
// free, and a pattern may hold one free repeat.
|
|
88
|
+
const literalAt = (pattern, i) => {
|
|
89
|
+
const char = pattern[i];
|
|
90
|
+
if (char === undefined) return null;
|
|
91
|
+
if (char === "\\") return pattern[i + 1] && !/[a-z0-9]/i.test(pattern[i + 1]) ? pattern[i + 1] : null;
|
|
92
|
+
return ".[](){}*+?|^$".includes(char) ? null : char;
|
|
93
|
+
};
|
|
94
|
+
function freeRepeats(pattern) {
|
|
95
|
+
let free = 0;
|
|
96
|
+
for (let i = 0; i < pattern.length;) {
|
|
97
|
+
const start = i;
|
|
98
|
+
const char = pattern[i];
|
|
99
|
+
if (char === "\\") i += 2;
|
|
100
|
+
else if (char === "[") {
|
|
101
|
+
i++;
|
|
102
|
+
while (i < pattern.length && pattern[i] !== "]") i += pattern[i] === "\\" ? 2 : 1;
|
|
103
|
+
i++;
|
|
104
|
+
} else if (char === "(") {
|
|
105
|
+
i += pattern[i + 1] === "?" ? 3 : 1;
|
|
106
|
+
continue;
|
|
107
|
+
} else if (char === "|" || char === "^" || char === "$") {
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
} else i++;
|
|
111
|
+
// A group's text can't be tried against one character.
|
|
112
|
+
const atom = char === ")" ? null : pattern.slice(start, i);
|
|
113
|
+
const quantifier = pattern[i];
|
|
114
|
+
let repeats = quantifier === "*" || quantifier === "+";
|
|
115
|
+
if (quantifier === "{") {
|
|
116
|
+
const bounds = /^\{(\d*)(,(\d*))?\}/.exec(pattern.slice(i));
|
|
117
|
+
if (!bounds) continue;
|
|
118
|
+
// "{3}" is one length, not a choice; "{2,}" and "{2,9}" are choices.
|
|
119
|
+
repeats = bounds[2] !== undefined && bounds[3] !== bounds[1];
|
|
120
|
+
i += bounds[0].length - 1;
|
|
121
|
+
} else if (quantifier !== "?" && !repeats) continue;
|
|
122
|
+
i++;
|
|
123
|
+
if (pattern[i] === "?") i++;
|
|
124
|
+
if (!repeats) continue;
|
|
125
|
+
const next = literalAt(pattern, i);
|
|
126
|
+
let pinned = false;
|
|
127
|
+
if (atom && next !== null) {
|
|
128
|
+
try {
|
|
129
|
+
pinned = !new RegExp(`^(?:${atom})$`, "i").test(next);
|
|
130
|
+
} catch {
|
|
131
|
+
// not an atom on its own: free
|
|
62
132
|
}
|
|
63
133
|
}
|
|
134
|
+
if (!pinned) free++;
|
|
64
135
|
}
|
|
65
|
-
return
|
|
136
|
+
return free;
|
|
66
137
|
}
|
|
67
138
|
|
|
68
139
|
function compileSection(section) {
|
|
@@ -130,7 +201,8 @@ function matchRequest(rules, { method = "", path = "", userAgent = "" } = {}) {
|
|
|
130
201
|
const rawQuery = lower(mark === -1 ? "" : target.slice(mark + 1));
|
|
131
202
|
const ua = lower(userAgent);
|
|
132
203
|
const decodedPath = normalizePath(decodeOnce(mark === -1 ? target : target.slice(0, mark)));
|
|
133
|
-
|
|
204
|
+
// Lowered again after decoding: %55NION is UNION, and the patterns are lower case.
|
|
205
|
+
const decodedQuery = decodeOnce(rawQuery).toLowerCase();
|
|
134
206
|
const upper = String(method ?? "").toUpperCase();
|
|
135
207
|
for (const rule of rules) {
|
|
136
208
|
if (rule.methods && !rule.methods.has(upper)) continue;
|
package/lib/resources.js
CHANGED
|
@@ -384,7 +384,7 @@ function createResources(core, options = {}) {
|
|
|
384
384
|
if (res.ok) {
|
|
385
385
|
const data = await res.json().catch(() => null);
|
|
386
386
|
if (Number(data?.every) > 0) every = Math.max(RETRY_S, Number(data.every));
|
|
387
|
-
if (data?.enabled === false) log("server resources are off in the dashboard (Security →
|
|
387
|
+
if (data?.enabled === false) log("server resources are off in the dashboard (Security → Server package)");
|
|
388
388
|
for (const minute of batch) rows.delete(minute);
|
|
389
389
|
return true;
|
|
390
390
|
}
|
package/package.json
CHANGED