analytics-platform 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +247 -2
- package/index.d.ts +184 -0
- package/index.js +128 -0
- package/lib/analytics.js +105 -0
- package/lib/audit.js +57 -0
- package/lib/bans.js +78 -0
- package/lib/core.js +122 -0
- package/lib/crawlers.js +216 -0
- package/lib/errors.js +178 -0
- package/lib/firewall.js +262 -0
- package/lib/flags.js +41 -0
- package/lib/forms.js +81 -0
- package/lib/headers.js +22 -0
- package/lib/inventory.js +142 -0
- package/lib/ip.js +104 -0
- package/lib/maintenance.js +81 -0
- package/lib/next.js +251 -0
- package/lib/performance.js +120 -0
- package/lib/posture.js +226 -0
- package/lib/proxy.js +198 -0
- package/lib/requestRules.js +217 -0
- package/lib/resources.js +453 -0
- package/next.d.ts +36 -0
- package/package.json +41 -4
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Performance & API monitoring module: times every request (from arriving to
|
|
2
|
+
// the response being sent) and sends one row per minute, method, route and
|
|
3
|
+
// status code (how many, total and slowest time, and a count per time bucket)
|
|
4
|
+
// to POST /events/server-requests. Nothing about visitors is sent.
|
|
5
|
+
//
|
|
6
|
+
// The route is the framework's own pattern when there is one (Express
|
|
7
|
+
// "/users/:id"); otherwise numbers and IDs in the path become ":id", and
|
|
8
|
+
// unmatched 404s are one route, so random paths don't make thousands.
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const os = require("node:os");
|
|
12
|
+
|
|
13
|
+
// Keep in step with the collector (expresscollect/Analytics/serverRequests.js).
|
|
14
|
+
const BUCKET_EDGES_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
15
|
+
const FLUSH_MS = 60 * 1000;
|
|
16
|
+
const MAX_KEYS = 300;
|
|
17
|
+
const MAX_ROWS = 500;
|
|
18
|
+
const NOT_FOUND = "/(not found)";
|
|
19
|
+
|
|
20
|
+
// "/orders/123/items/9f8e..." -> "/orders/:id/items/:id"
|
|
21
|
+
function normalizeRoute(url) {
|
|
22
|
+
const path = String(url || "/").split(/[?#]/)[0] || "/";
|
|
23
|
+
const route = path.split("/").map((segment) => (
|
|
24
|
+
/^\d+$/.test(segment) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)
|
|
25
|
+
|| /^[0-9a-f]{16,}$/i.test(segment) || (segment.length >= 20 && /\d/.test(segment))
|
|
26
|
+
? ":id" : segment
|
|
27
|
+
)).join("/");
|
|
28
|
+
return (route.startsWith("/") ? route : `/${route}`).slice(0, 200);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bucketOf(ms) {
|
|
32
|
+
const i = BUCKET_EDGES_MS.findIndex((edge) => ms <= edge);
|
|
33
|
+
return i === -1 ? BUCKET_EDGES_MS.length : i;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Options: serverName (default the host name), route(req) (your own route
|
|
37
|
+
// name for a request, or "" to leave it out).
|
|
38
|
+
function createPerformance(core, options = {}) {
|
|
39
|
+
const log = (...args) => core.log("performance", ...args);
|
|
40
|
+
const serverName = String(options.serverName ?? os.hostname()).slice(0, 100);
|
|
41
|
+
const runtime = `Node ${process.versions.node}`;
|
|
42
|
+
let rows = new Map();
|
|
43
|
+
const keysIn = new Map(); // minute -> rows it has
|
|
44
|
+
let sending = Promise.resolve();
|
|
45
|
+
|
|
46
|
+
// Records one finished request.
|
|
47
|
+
function record({ method, route, status, ms }) {
|
|
48
|
+
if (!core.isOn("performance")) return;
|
|
49
|
+
const minute = Math.floor(Date.now() / 60000) * 60000;
|
|
50
|
+
let key = `${minute}|${method}|${route}|${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}`;
|
|
54
|
+
let row = rows.get(key);
|
|
55
|
+
if (!row) {
|
|
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) };
|
|
57
|
+
rows.set(key, row);
|
|
58
|
+
keysIn.set(minute, used + 1);
|
|
59
|
+
}
|
|
60
|
+
row.count += 1;
|
|
61
|
+
row.sum_ms += ms;
|
|
62
|
+
if (ms > row.max_ms) row.max_ms = ms;
|
|
63
|
+
row.buckets[bucketOf(ms)] += 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function routeOf(req, status) {
|
|
67
|
+
const own = options.route?.(req);
|
|
68
|
+
if (own) return String(own).slice(0, 200);
|
|
69
|
+
if (req.route?.path && typeof req.route.path === "string") return `${req.baseUrl || ""}${req.route.path}`.slice(0, 200);
|
|
70
|
+
return status === 404 ? NOT_FOUND : normalizeRoute(req.originalUrl || req.url);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Express / Connect / node:http middleware.
|
|
74
|
+
function middleware(req, res, next = () => {}) {
|
|
75
|
+
const start = process.hrtime.bigint();
|
|
76
|
+
res.once("finish", () => {
|
|
77
|
+
try {
|
|
78
|
+
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
79
|
+
const status = res.statusCode || 0;
|
|
80
|
+
record({ method: String(req.method || "GET").toUpperCase().slice(0, 10), route: routeOf(req, status), status, ms });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
log("could not time a request:", error?.message);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
next();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sends the rows of finished minutes (all of them with all = true).
|
|
89
|
+
function flush(all = false) {
|
|
90
|
+
const current = Math.floor(Date.now() / 60000) * 60000;
|
|
91
|
+
const ready = [];
|
|
92
|
+
for (const [key, row] of rows) {
|
|
93
|
+
if (all || row.minute < current) {
|
|
94
|
+
ready.push({ ...row, minute: new Date(row.minute).toISOString(), sum_ms: Math.round(row.sum_ms * 10) / 10, max_ms: Math.round(row.max_ms * 10) / 10 });
|
|
95
|
+
rows.delete(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const minute of keysIn.keys()) if (all || minute < current) keysIn.delete(minute);
|
|
99
|
+
for (let i = 0; i < ready.length; i += MAX_ROWS) {
|
|
100
|
+
const batch = ready.slice(i, i + MAX_ROWS);
|
|
101
|
+
sending = sending.then(() => core.send("performance", "/events/server-requests", { body: { server_name: serverName, runtime, rows: batch } }));
|
|
102
|
+
}
|
|
103
|
+
return sending;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timer = core.valid ? setInterval(() => flush(), FLUSH_MS) : null;
|
|
107
|
+
timer?.unref?.();
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
middleware,
|
|
111
|
+
record,
|
|
112
|
+
flush: () => flush(true),
|
|
113
|
+
close() {
|
|
114
|
+
clearInterval(timer);
|
|
115
|
+
return flush(true);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { createPerformance, normalizeRoute, BUCKET_EDGES_MS };
|
package/lib/posture.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// How the server is set up (protocol in sdk/protocols/posture.md): the app
|
|
2
|
+
// settings that leave it open, whether it runs as root, and the files in the
|
|
3
|
+
// folders it serves to the web that should never be public. Sent to
|
|
4
|
+
// POST /events/posture a few minutes after start and then once a day (the
|
|
5
|
+
// collector's `every`), so Security → Security check can say what to change.
|
|
6
|
+
//
|
|
7
|
+
// It only reads: nothing here changes a setting, a permission or a file, and
|
|
8
|
+
// no file's contents are sent — paths, sizes, times and short values only.
|
|
9
|
+
// The walk is bounded and runs in the background, never in a request; with no
|
|
10
|
+
// folder served to the web there is nothing to walk and only the settings are
|
|
11
|
+
// reported.
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const fs = require("node:fs");
|
|
15
|
+
const os = require("node:os");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const FIRST_MS = 3 * 60 * 1000;
|
|
19
|
+
const RETRY_S = 60 * 60;
|
|
20
|
+
const EVERY_S = 24 * 60 * 60;
|
|
21
|
+
const MAX_CHECKS = 200;
|
|
22
|
+
const MAX_EXPOSURE = 50;
|
|
23
|
+
const MAX_ENTRIES = 20000;
|
|
24
|
+
const MAX_DEPTH = 6;
|
|
25
|
+
const MAX_PERM_CHECKS = 3000;
|
|
26
|
+
// Folders that are conventionally served to the web, when the app has them.
|
|
27
|
+
const STATIC_DIRS = ["public", "static"];
|
|
28
|
+
const SKIP_DIRS = new Set([".git", ".svn", ".hg", "node_modules", "vendor", "cache", ".cache", ".next", ".history"]);
|
|
29
|
+
const CODE_FILE = /\.(js|cjs|mjs|jsx|tsx?|php[0-9]?|phtml|py|rb|pl|sh)$/i;
|
|
30
|
+
// Files that shouldn't be downloadable, by name; first match wins.
|
|
31
|
+
const EXPOSURE = [
|
|
32
|
+
["database_dump", /\.sql(\.(gz|bz2|xz|zip))?$/i],
|
|
33
|
+
["private_key", /^(id_rsa|id_dsa|id_ecdsa|id_ed25519)$|\.(pem|key|p12|pfx|jks)$/i],
|
|
34
|
+
["env", /^\.env($|\.)|^env\.(ini|txt)$/i],
|
|
35
|
+
["archive", /\.(zip|tar|tgz|tar\.gz|tar\.bz2|rar|7z)$/i],
|
|
36
|
+
["backup", /\.(bak|old|orig|save|swp|copy|backup)$|~$|\.(js|json|conf|config|ini|ya?ml)\.(bak|old|orig|save|txt)$/i],
|
|
37
|
+
["info", /^(phpinfo|info|adminer)\.php$/i],
|
|
38
|
+
["log", /^(debug|error|access)[-_.]?log$|\.log$/i],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const kindOf = (name) => (EXPOSURE.find(([, pattern]) => pattern.test(name)) || [null])[0];
|
|
42
|
+
|
|
43
|
+
// Each root as { dir, prefix }: `prefix` is where the web serves it, so the
|
|
44
|
+
// path we report is the one the dashboard can ask the site for.
|
|
45
|
+
// A folder inside another folder on the list is already covered: without this,
|
|
46
|
+
// every file under it would be reported twice.
|
|
47
|
+
function withoutNested(roots) {
|
|
48
|
+
const seen = new Map(roots.map((root) => [root.dir, root]));
|
|
49
|
+
return [...seen.values()].filter((root, _, all) =>
|
|
50
|
+
!all.some((other) => other.dir !== root.dir && `${root.dir}/`.startsWith(`${other.dir}/`)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rootsOf(given, cwd) {
|
|
54
|
+
const list = [];
|
|
55
|
+
for (const entry of Array.isArray(given) ? given : []) {
|
|
56
|
+
const dir = typeof entry === "string" ? entry : entry?.dir;
|
|
57
|
+
if (!dir) continue;
|
|
58
|
+
const prefix = String((typeof entry === "object" && entry.prefix) || "").replace(/^\/+|\/+$/g, "");
|
|
59
|
+
list.push({ dir: path.resolve(cwd, String(dir)), prefix });
|
|
60
|
+
}
|
|
61
|
+
if (list.length) return withoutNested(list);
|
|
62
|
+
// Not told: the conventional folders, when they're there (Express static,
|
|
63
|
+
// Next.js public).
|
|
64
|
+
return withoutNested(STATIC_DIRS
|
|
65
|
+
.map((name) => ({ dir: path.join(cwd, name), prefix: "" }))
|
|
66
|
+
.filter(({ dir }) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One bounded walk per root: files that shouldn't be public, and code any
|
|
70
|
+
// account on the machine can write.
|
|
71
|
+
async function walk(roots) {
|
|
72
|
+
const exposure = [];
|
|
73
|
+
const examples = [];
|
|
74
|
+
let writable = 0;
|
|
75
|
+
let checked = 0;
|
|
76
|
+
let entries = 0;
|
|
77
|
+
for (const root of roots) {
|
|
78
|
+
const queue = [[root.dir, 0]];
|
|
79
|
+
while (queue.length) {
|
|
80
|
+
const [dir, depth] = queue.shift();
|
|
81
|
+
let names;
|
|
82
|
+
try {
|
|
83
|
+
names = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const item of names) {
|
|
88
|
+
if (++entries > MAX_ENTRIES) return { exposure, writable: checked ? writable : null, examples, truncated: true };
|
|
89
|
+
if (item.isSymbolicLink()) continue;
|
|
90
|
+
const full = path.join(dir, item.name);
|
|
91
|
+
const relative = [root.prefix, path.relative(root.dir, full).split(path.sep).join("/")].filter(Boolean).join("/");
|
|
92
|
+
if (item.isDirectory()) {
|
|
93
|
+
if (/^\.(git|svn|hg|bzr)$/.test(item.name)) {
|
|
94
|
+
if (exposure.length < MAX_EXPOSURE) exposure.push({ path: `${relative}/config`, kind: "vcs" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (depth + 1 <= MAX_DEPTH && !SKIP_DIRS.has(item.name)) queue.push([full, depth + 1]);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!item.isFile()) continue;
|
|
101
|
+
const kind = kindOf(item.name);
|
|
102
|
+
if (kind && exposure.length < MAX_EXPOSURE) {
|
|
103
|
+
const file = { path: relative, kind };
|
|
104
|
+
try {
|
|
105
|
+
const info = await fs.promises.stat(full);
|
|
106
|
+
file.size = info.size;
|
|
107
|
+
file.mtime = new Date(info.mtimeMs).toISOString();
|
|
108
|
+
} catch {
|
|
109
|
+
// reported without its size
|
|
110
|
+
}
|
|
111
|
+
exposure.push(file);
|
|
112
|
+
}
|
|
113
|
+
if (checked < MAX_PERM_CHECKS && CODE_FILE.test(item.name)) {
|
|
114
|
+
checked++;
|
|
115
|
+
try {
|
|
116
|
+
// The "others may write" bit: any account on the machine can rewrite it.
|
|
117
|
+
if ((await fs.promises.stat(full)).mode & 0o002) {
|
|
118
|
+
writable++;
|
|
119
|
+
if (examples.length < 3) examples.push(relative);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// left out
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { exposure, writable: checked ? writable : null, examples, truncated: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// What the app itself can say about how it's set up.
|
|
132
|
+
function appChecks() {
|
|
133
|
+
const checks = [];
|
|
134
|
+
const add = (id, failed, value) => {
|
|
135
|
+
if (failed === null) return checks.push({ id, state: "unknown" });
|
|
136
|
+
checks.push({ id, state: failed ? "fail" : "pass", ...(value ? { value } : {}) });
|
|
137
|
+
};
|
|
138
|
+
add("app.node_env", process.env.NODE_ENV !== "production", process.env.NODE_ENV ? `NODE_ENV=${process.env.NODE_ENV}` : "NODE_ENV is not set");
|
|
139
|
+
// Running the web app as root turns any hole in it into the whole machine.
|
|
140
|
+
if (typeof process.getuid === "function") {
|
|
141
|
+
add("server.runs_as_root", process.getuid() === 0);
|
|
142
|
+
}
|
|
143
|
+
return checks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Options: roots (the folders the web serves, as "public" or
|
|
147
|
+
// { dir, prefix: "static" }; default: ./public and ./static when they exist),
|
|
148
|
+
// host (the server's name; default the host name), checks (a function
|
|
149
|
+
// returning more checks of your own).
|
|
150
|
+
function createPosture(core, options = {}) {
|
|
151
|
+
const log = (...args) => core.log("posture", ...args);
|
|
152
|
+
const cwd = String(options.cwd || process.cwd());
|
|
153
|
+
const roots = rootsOf(options.roots, cwd);
|
|
154
|
+
const host = String(options.host ?? os.hostname()).replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 100) || "server";
|
|
155
|
+
let timer = null;
|
|
156
|
+
let closed = false;
|
|
157
|
+
|
|
158
|
+
async function report() {
|
|
159
|
+
let wait = RETRY_S;
|
|
160
|
+
try {
|
|
161
|
+
// The set-up checks ride the software inventory switch.
|
|
162
|
+
if (core.isOn("inventory")) {
|
|
163
|
+
const { exposure, writable, examples, truncated } = await walk(roots);
|
|
164
|
+
const checks = appChecks();
|
|
165
|
+
if (writable !== null) {
|
|
166
|
+
checks.push({
|
|
167
|
+
id: "server.code_writable",
|
|
168
|
+
state: writable ? "fail" : "pass",
|
|
169
|
+
...(writable ? { value: `${writable} files, e.g. ${examples.join(", ")}` } : {}),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const check of typeof options.checks === "function" ? await options.checks() : []) {
|
|
173
|
+
if (check?.id && ["pass", "fail", "unknown"].includes(check.state) && checks.length < MAX_CHECKS) {
|
|
174
|
+
checks.push({ id: String(check.id).toLowerCase().slice(0, 60), state: check.state, ...(check.value ? { value: String(check.value).slice(0, 200) } : {}) });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const res = await core.send("posture", "/events/posture", {
|
|
178
|
+
auth: "bearer",
|
|
179
|
+
body: {
|
|
180
|
+
site_id: core.siteId,
|
|
181
|
+
host,
|
|
182
|
+
at: new Date().toISOString(),
|
|
183
|
+
checks: checks.slice(0, MAX_CHECKS),
|
|
184
|
+
facts: {
|
|
185
|
+
agent: `node/${process.versions.node}`,
|
|
186
|
+
...(roots.length ? { webroot: roots[0].dir } : {}),
|
|
187
|
+
},
|
|
188
|
+
// Left out when there's no folder to walk: the dashboard then says
|
|
189
|
+
// this wasn't checked, instead of taking an empty walk for a clean one.
|
|
190
|
+
...(roots.length ? { exposure: exposure.slice(0, MAX_EXPOSURE) } : {}),
|
|
191
|
+
...(truncated ? { truncated: true } : {}),
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
if (res?.ok) {
|
|
195
|
+
const data = await res.json().catch(() => null);
|
|
196
|
+
wait = Number(data?.every) > 0 ? Math.max(RETRY_S, Number(data.every)) : EVERY_S;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
log("could not send the set-up checks:", error?.message);
|
|
201
|
+
}
|
|
202
|
+
schedule(wait * 1000);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function schedule(ms) {
|
|
206
|
+
if (closed) return;
|
|
207
|
+
// A report called by hand restarts the wait rather than starting a second chain.
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
timer = setTimeout(report, ms);
|
|
210
|
+
timer.unref?.();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (core.valid) schedule(FIRST_MS);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
// What would be reported now (for your own checks and tests).
|
|
217
|
+
report,
|
|
218
|
+
roots,
|
|
219
|
+
close() {
|
|
220
|
+
closed = true;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { createPosture, rootsOf, withoutNested, walk, appChecks, kindOf };
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// First-party proxy: serving the tracker from the site's own domain, so ad
|
|
2
|
+
// blockers don't hide visitors.
|
|
3
|
+
//
|
|
4
|
+
// Blockers stop analytics by recognising the address a script comes from and
|
|
5
|
+
// what it is called ("track.js", "collect"). The answer is to serve both from
|
|
6
|
+
// the site's own domain, which until now meant writing Nginx, Cloudflare or
|
|
7
|
+
// Vercel rules by hand. This app is already a server on that domain, so the
|
|
8
|
+
// package does it itself:
|
|
9
|
+
//
|
|
10
|
+
// <prefix>/js/<rest> -> <endpoint>/<rest> (tracker files)
|
|
11
|
+
// <prefix>/api/<rest> -> <endpoint>/collectanalytics/<rest> (the collector)
|
|
12
|
+
//
|
|
13
|
+
// The same two rules as the hand-written ones, so one install check covers
|
|
14
|
+
// both. The prefix comes from the site secret: not guessable from the page,
|
|
15
|
+
// and the same every time, so a browser's cache and a CDN in front stay valid.
|
|
16
|
+
//
|
|
17
|
+
// It runs before every other module: a visitor's beacon is our own traffic,
|
|
18
|
+
// not theirs, so it shouldn't be counted as a page of the site, timed as one,
|
|
19
|
+
// or turned away by the firewall's rate limit.
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const crypto = require("node:crypto");
|
|
23
|
+
|
|
24
|
+
// The tracker files, by name: anything else is refused rather than forwarded.
|
|
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", "widget.js", "share.js"]);
|
|
27
|
+
// A pinned version never changes; /v<major>/ follows new releases, so it's
|
|
28
|
+
// held for an hour, the same as the collector serves it with.
|
|
29
|
+
const TTL_PINNED_MS = 7 * 24 * 60 * 60 * 1000;
|
|
30
|
+
const TTL_MAJOR_MS = 60 * 60 * 1000;
|
|
31
|
+
const TIMEOUT_MS = 5000;
|
|
32
|
+
const MAX_BODY = 6 * 1024 * 1024;
|
|
33
|
+
// The collector reads these; everything else, cookies above all, stays here.
|
|
34
|
+
const PASS_HEADERS = ["content-type", "user-agent", "accept-language", "referer", "origin",
|
|
35
|
+
"sec-fetch-site", "sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"];
|
|
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
|
+
|
|
40
|
+
const prefixFor = (secret) => crypto.createHash("sha256").update(`proxy|${secret}`).digest("hex").slice(0, 8);
|
|
41
|
+
|
|
42
|
+
// Options: prefix (default: from the secret), enabled (default true).
|
|
43
|
+
function createProxy(core, options = {}) {
|
|
44
|
+
const enabled = options.enabled !== false;
|
|
45
|
+
const prefix = String(options.prefix || prefixFor(core.secret)).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
46
|
+
const clientIp = typeof options.clientIp === "function" ? options.clientIp : null;
|
|
47
|
+
// One copy of each tracker file, so a repeat visitor costs no upstream
|
|
48
|
+
// request. A handful of small files: it never grows.
|
|
49
|
+
const files = new Map();
|
|
50
|
+
|
|
51
|
+
const on = () => enabled && core.valid && prefix !== "" && core.isOn("proxy");
|
|
52
|
+
const scriptUrl = (version = "1") => `/${prefix}/js/v${version}/s.js`;
|
|
53
|
+
const collectUrl = () => `/${prefix}/api/e`;
|
|
54
|
+
|
|
55
|
+
async function serveFile(rest, res) {
|
|
56
|
+
const match = /^v(\d+(?:\.\d+\.\d+)?)\/([a-z]+\.js)$/.exec(rest);
|
|
57
|
+
if (!match || !FILES.has(match[2])) return false;
|
|
58
|
+
const pinned = match[1].includes(".");
|
|
59
|
+
const held = files.get(rest);
|
|
60
|
+
let body = held && held.at > Date.now() - (pinned ? TTL_PINNED_MS : TTL_MAJOR_MS) ? held.body : null;
|
|
61
|
+
if (body === null) {
|
|
62
|
+
try {
|
|
63
|
+
const answer = await fetch(`${core.endpoint}/${rest}`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
64
|
+
const text = answer.ok ? await answer.text() : "";
|
|
65
|
+
if (text.length > 500) {
|
|
66
|
+
body = text;
|
|
67
|
+
files.set(rest, { body, at: Date.now() });
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
core.log("proxy", `could not fetch ${rest}: ${error?.message}`);
|
|
71
|
+
}
|
|
72
|
+
// Upstream is unreachable: an old copy beats no tracker at all.
|
|
73
|
+
if (body === null && held) body = held.body;
|
|
74
|
+
}
|
|
75
|
+
if (body === null) {
|
|
76
|
+
res.writeHead(502).end();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
res.writeHead(200, {
|
|
80
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
81
|
+
"cache-control": pinned ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
82
|
+
"x-content-type-options": "nosniff",
|
|
83
|
+
"content-length": Buffer.byteLength(body),
|
|
84
|
+
}).end(body);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The request's body, however the app is put together: straight from the
|
|
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.
|
|
93
|
+
function readBody(req) {
|
|
94
|
+
const skipped = req._body !== true && isEmptyObject(req.body);
|
|
95
|
+
if (req.readableEnded || (req.body !== undefined && !skipped)) {
|
|
96
|
+
const parsed = req.body;
|
|
97
|
+
if (parsed === undefined || parsed === null) return Promise.resolve("");
|
|
98
|
+
if (typeof parsed === "string" || Buffer.isBuffer(parsed)) return Promise.resolve(parsed);
|
|
99
|
+
return Promise.resolve(JSON.stringify(parsed));
|
|
100
|
+
}
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
req.on("data", (chunk) => {
|
|
105
|
+
size += chunk.length;
|
|
106
|
+
if (size > MAX_BODY) {
|
|
107
|
+
req.destroy();
|
|
108
|
+
reject(new Error("too large"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
chunks.push(chunk);
|
|
112
|
+
});
|
|
113
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
114
|
+
req.on("error", reject);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function serveCollector(rest, req, res, search) {
|
|
119
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
120
|
+
if (method === "OPTIONS") {
|
|
121
|
+
// Same origin as the page, so a preflight shouldn't happen; answer it
|
|
122
|
+
// cheaply rather than passing it on.
|
|
123
|
+
res.writeHead(204, {
|
|
124
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
125
|
+
"access-control-allow-headers": "Content-Type",
|
|
126
|
+
"access-control-max-age": "86400",
|
|
127
|
+
}).end();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (method !== "GET" && method !== "POST") {
|
|
131
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let body;
|
|
135
|
+
try {
|
|
136
|
+
body = method === "POST" ? await readBody(req) : undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
res.writeHead(413).end();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const headers = {
|
|
142
|
+
// The visitor's address: the collector needs it for the country, the
|
|
143
|
+
// daily visitor ID and the per-visitor rate limit.
|
|
144
|
+
"x-forwarded-for": (clientIp ? clientIp(req) : req.ip) || req.socket?.remoteAddress || "",
|
|
145
|
+
"x-forwarded-proto": req.socket?.encrypted ? "https" : "http",
|
|
146
|
+
};
|
|
147
|
+
for (const name of PASS_HEADERS) {
|
|
148
|
+
if (req.headers[name]) headers[name] = req.headers[name];
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const answer = await fetch(`${core.endpoint}/collectanalytics/${rest}${search}`, {
|
|
152
|
+
method,
|
|
153
|
+
headers,
|
|
154
|
+
body,
|
|
155
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
156
|
+
});
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const name of ["content-type", "cache-control", "retry-after"]) {
|
|
159
|
+
const value = answer.headers.get(name);
|
|
160
|
+
if (value) out[name] = value;
|
|
161
|
+
}
|
|
162
|
+
res.writeHead(answer.status, out).end(Buffer.from(await answer.arrayBuffer()));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// The tracker already treats a failed beacon as lost and carries on.
|
|
165
|
+
core.log("proxy", `could not reach the collector: ${error?.message}`);
|
|
166
|
+
res.writeHead(502).end();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Express / Connect / node:http middleware. Anything that isn't ours is
|
|
171
|
+
// passed straight on, so the cost to every other request is one compare.
|
|
172
|
+
function middleware(req, res, next = () => {}) {
|
|
173
|
+
if (!on()) return next();
|
|
174
|
+
const url = String(req.originalUrl || req.url || "");
|
|
175
|
+
if (!url.startsWith(`/${prefix}/`)) return next();
|
|
176
|
+
const [path, search = ""] = url.split("?");
|
|
177
|
+
const rest = path.slice(prefix.length + 2);
|
|
178
|
+
const kind = rest.startsWith("js/") ? "js" : rest.startsWith("api/") ? "api" : "";
|
|
179
|
+
const tail = rest.slice(kind.length + 1);
|
|
180
|
+
if (kind === "" || tail === "" || tail.includes("..") || !/^[A-Za-z0-9/_.-]{1,120}$/.test(tail)) {
|
|
181
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const done = kind === "js"
|
|
185
|
+
? serveFile(tail, res).then((served) => {
|
|
186
|
+
if (!served) res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
187
|
+
})
|
|
188
|
+
: serveCollector(tail, req, res, search ? `?${search}` : "");
|
|
189
|
+
return done.catch((error) => {
|
|
190
|
+
core.log("proxy", `failed: ${error?.message}`);
|
|
191
|
+
if (!res.headersSent) res.writeHead(502).end();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { prefix, scriptUrl, collectUrl, middleware, enabled: on };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { createProxy, prefixFor };
|