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 CHANGED
@@ -57,7 +57,7 @@ createPlatform({
57
57
  });
58
58
  ```
59
59
 
60
- **Switch modules off in the dashboard** (Security → Settings → Server package) without a
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
- // Form checks find the visitor's address the way the firewall does.
50
- if (["forms", "audit", "maintenance", "crawlers", "proxy"].includes(name) && modules.firewall) opts.clientIp = modules.firewall.clientIp;
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(ip) {
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(ip, seconds) {
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(ip, login) {
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(ip, login.ban);
53
+ ban(address, login.ban);
42
54
  logins.delete(ip);
43
55
  return true;
44
56
  }
package/lib/core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // Sending never throws into the app.
7
7
  "use strict";
8
8
 
9
- const VERSION = "0.1.0";
9
+ const VERSION = "0.2.2";
10
10
  const TIMEOUT_MS = 5000;
11
11
  const MIN_SYNC_S = 30;
12
12
  const RETRY_S = 60;
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
- if (!rows.has(key) && rows.size >= MAX_KEYS) key = `${minute}|${bot}|${OTHER}|${status}`;
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.listenerCount("uncaughtException") > 1) return;
147
+ if (exiting || process.listeners("uncaughtException").some((listener) => !handlers.has(listener))) return;
148
+ exiting = true;
144
149
  console.error(error);
145
- const exit = setTimeout(() => process.exit(1), EXIT_FLUSH_MS);
146
- flush().finally(() => {
147
- clearTimeout(exit);
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) process.on("uncaughtException", onUncaught);
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 site's other servers.
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 → Settings → Server package), for everyone, for a share of users, or
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";
@@ -1,4 +1,4 @@
1
- // Maintenance mode module: while it's on (Security → Settings → Server package), visitors
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 === name) return decodeURIComponent(v.join("="));
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
- const url = new URL(req.originalUrl || req.url || "/", "http://x");
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
  }
@@ -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
- if (!rows.has(key) && rows.size >= MAX_KEYS) key = `${minute}|${method}|(other)|${status}`;
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
- if (req.readableEnded || req.body !== undefined) {
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) : "") || req.socket?.remoteAddress || "",
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) {
@@ -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
- function decodeOnce(text) {
24
- if (!text.includes("%") && !text.includes("+")) return text;
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(text.replace(/\+/g, " "));
28
+ return decodeURIComponent(run);
27
29
  } catch {
28
- return text;
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 quantifier on a group that itself repeats ("(a+)+") takes
37
- // exponential time on input that nearly matches. Backreferences and lookbehind
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 === "(") open.push(i);
55
- else if (char === ")") {
56
- const start = open.pop();
57
- if (start === undefined) return false;
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
- const inside = pattern.slice(start + 1, i);
61
- if (/(^|[^\\])[*+]/.test(inside) || /(^|[^\\])\{\d/.test(inside)) return false;
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 open.length === 0;
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
- const decodedQuery = decodeOnce(rawQuery);
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 → Settings → Server package)");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "analytics-platform",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Firewall, error monitoring and server analytics for your Node server, run from your analytics dashboard.",
5
5
  "author": "steviec87",
6
6
  "license": "MIT",