analytics-platform 0.0.0-stage → 0.2.1
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 +124 -0
- package/lib/analytics.js +105 -0
- package/lib/audit.js +57 -0
- package/lib/bans.js +66 -0
- package/lib/core.js +122 -0
- package/lib/crawlers.js +211 -0
- package/lib/errors.js +165 -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 +67 -0
- package/lib/next.js +249 -0
- package/lib/performance.js +115 -0
- package/lib/posture.js +224 -0
- package/lib/proxy.js +191 -0
- package/lib/requestRules.js +145 -0
- package/lib/resources.js +453 -0
- package/next.d.ts +36 -0
- package/package.json +41 -4
package/lib/posture.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
timer = setTimeout(report, ms);
|
|
208
|
+
timer.unref?.();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (core.valid) schedule(FIRST_MS);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
// What would be reported now (for your own checks and tests).
|
|
215
|
+
report,
|
|
216
|
+
roots,
|
|
217
|
+
close() {
|
|
218
|
+
closed = true;
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { createPosture, rootsOf, withoutNested, walk, appChecks, kindOf };
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
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"]);
|
|
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 prefixFor = (secret) => crypto.createHash("sha256").update(`proxy|${secret}`).digest("hex").slice(0, 8);
|
|
38
|
+
|
|
39
|
+
// Options: prefix (default: from the secret), enabled (default true).
|
|
40
|
+
function createProxy(core, options = {}) {
|
|
41
|
+
const enabled = options.enabled !== false;
|
|
42
|
+
const prefix = String(options.prefix || prefixFor(core.secret)).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
43
|
+
const clientIp = typeof options.clientIp === "function" ? options.clientIp : null;
|
|
44
|
+
// One copy of each tracker file, so a repeat visitor costs no upstream
|
|
45
|
+
// request. A handful of small files: it never grows.
|
|
46
|
+
const files = new Map();
|
|
47
|
+
|
|
48
|
+
const on = () => enabled && core.valid && prefix !== "" && core.isOn("proxy");
|
|
49
|
+
const scriptUrl = (version = "1") => `/${prefix}/js/v${version}/s.js`;
|
|
50
|
+
const collectUrl = () => `/${prefix}/api/e`;
|
|
51
|
+
|
|
52
|
+
async function serveFile(rest, res) {
|
|
53
|
+
const match = /^v(\d+(?:\.\d+\.\d+)?)\/([a-z]+\.js)$/.exec(rest);
|
|
54
|
+
if (!match || !FILES.has(match[2])) return false;
|
|
55
|
+
const pinned = match[1].includes(".");
|
|
56
|
+
const held = files.get(rest);
|
|
57
|
+
let body = held && held.at > Date.now() - (pinned ? TTL_PINNED_MS : TTL_MAJOR_MS) ? held.body : null;
|
|
58
|
+
if (body === null) {
|
|
59
|
+
try {
|
|
60
|
+
const answer = await fetch(`${core.endpoint}/${rest}`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
61
|
+
const text = answer.ok ? await answer.text() : "";
|
|
62
|
+
if (text.length > 500) {
|
|
63
|
+
body = text;
|
|
64
|
+
files.set(rest, { body, at: Date.now() });
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
core.log("proxy", `could not fetch ${rest}: ${error?.message}`);
|
|
68
|
+
}
|
|
69
|
+
// Upstream is unreachable: an old copy beats no tracker at all.
|
|
70
|
+
if (body === null && held) body = held.body;
|
|
71
|
+
}
|
|
72
|
+
if (body === null) {
|
|
73
|
+
res.writeHead(502).end();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
78
|
+
"cache-control": pinned ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
79
|
+
"x-content-type-options": "nosniff",
|
|
80
|
+
"content-length": Buffer.byteLength(body),
|
|
81
|
+
}).end(body);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The request's body, however the app is put together: straight from the
|
|
86
|
+
// stream, or from whatever a body parser mounted before us already read.
|
|
87
|
+
function readBody(req) {
|
|
88
|
+
if (req.readableEnded || req.body !== undefined) {
|
|
89
|
+
const parsed = req.body;
|
|
90
|
+
if (parsed === undefined || parsed === null) return Promise.resolve("");
|
|
91
|
+
if (typeof parsed === "string" || Buffer.isBuffer(parsed)) return Promise.resolve(parsed);
|
|
92
|
+
return Promise.resolve(JSON.stringify(parsed));
|
|
93
|
+
}
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let size = 0;
|
|
97
|
+
req.on("data", (chunk) => {
|
|
98
|
+
size += chunk.length;
|
|
99
|
+
if (size > MAX_BODY) {
|
|
100
|
+
req.destroy();
|
|
101
|
+
reject(new Error("too large"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
chunks.push(chunk);
|
|
105
|
+
});
|
|
106
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
107
|
+
req.on("error", reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function serveCollector(rest, req, res, search) {
|
|
112
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
113
|
+
if (method === "OPTIONS") {
|
|
114
|
+
// Same origin as the page, so a preflight shouldn't happen; answer it
|
|
115
|
+
// cheaply rather than passing it on.
|
|
116
|
+
res.writeHead(204, {
|
|
117
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
118
|
+
"access-control-allow-headers": "Content-Type",
|
|
119
|
+
"access-control-max-age": "86400",
|
|
120
|
+
}).end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (method !== "GET" && method !== "POST") {
|
|
124
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let body;
|
|
128
|
+
try {
|
|
129
|
+
body = method === "POST" ? await readBody(req) : undefined;
|
|
130
|
+
} catch {
|
|
131
|
+
res.writeHead(413).end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const headers = {
|
|
135
|
+
// The visitor's address: the collector needs it for the country, the
|
|
136
|
+
// daily visitor ID and the per-visitor rate limit.
|
|
137
|
+
"x-forwarded-for": (clientIp ? clientIp(req) : "") || req.socket?.remoteAddress || "",
|
|
138
|
+
"x-forwarded-proto": req.socket?.encrypted ? "https" : "http",
|
|
139
|
+
};
|
|
140
|
+
for (const name of PASS_HEADERS) {
|
|
141
|
+
if (req.headers[name]) headers[name] = req.headers[name];
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const answer = await fetch(`${core.endpoint}/collectanalytics/${rest}${search}`, {
|
|
145
|
+
method,
|
|
146
|
+
headers,
|
|
147
|
+
body,
|
|
148
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
149
|
+
});
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const name of ["content-type", "cache-control", "retry-after"]) {
|
|
152
|
+
const value = answer.headers.get(name);
|
|
153
|
+
if (value) out[name] = value;
|
|
154
|
+
}
|
|
155
|
+
res.writeHead(answer.status, out).end(Buffer.from(await answer.arrayBuffer()));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// The tracker already treats a failed beacon as lost and carries on.
|
|
158
|
+
core.log("proxy", `could not reach the collector: ${error?.message}`);
|
|
159
|
+
res.writeHead(502).end();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Express / Connect / node:http middleware. Anything that isn't ours is
|
|
164
|
+
// passed straight on, so the cost to every other request is one compare.
|
|
165
|
+
function middleware(req, res, next = () => {}) {
|
|
166
|
+
if (!on()) return next();
|
|
167
|
+
const url = String(req.originalUrl || req.url || "");
|
|
168
|
+
if (!url.startsWith(`/${prefix}/`)) return next();
|
|
169
|
+
const [path, search = ""] = url.split("?");
|
|
170
|
+
const rest = path.slice(prefix.length + 2);
|
|
171
|
+
const kind = rest.startsWith("js/") ? "js" : rest.startsWith("api/") ? "api" : "";
|
|
172
|
+
const tail = rest.slice(kind.length + 1);
|
|
173
|
+
if (kind === "" || tail === "" || tail.includes("..") || !/^[A-Za-z0-9/_.-]{1,120}$/.test(tail)) {
|
|
174
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const done = kind === "js"
|
|
178
|
+
? serveFile(tail, res).then((served) => {
|
|
179
|
+
if (!served) res.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
180
|
+
})
|
|
181
|
+
: serveCollector(tail, req, res, search ? `?${search}` : "");
|
|
182
|
+
return done.catch((error) => {
|
|
183
|
+
core.log("proxy", `failed: ${error?.message}`);
|
|
184
|
+
if (!res.headersSent) res.writeHead(502).end();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { prefix, scriptUrl, collectUrl, middleware, enabled: on };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { createProxy, prefixFor };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Request rules (virtual patching; protocol in sdk/protocols/firewall.md):
|
|
2
|
+
// the firewall has always matched addresses, these match the request — its
|
|
3
|
+
// method, path, query string and user agent — so an attack is turned away
|
|
4
|
+
// before the vulnerable code runs. The rules come with the others, from the
|
|
5
|
+
// collector, and are compiled once per fetch; matching is plain string work on
|
|
6
|
+
// text we already have, so it costs nothing per request.
|
|
7
|
+
//
|
|
8
|
+
// Shared by the Express/node:http middleware and the Next.js edge one, so
|
|
9
|
+
// both decide the same way: nothing here touches the filesystem or the
|
|
10
|
+
// network, which is what the edge runtime allows.
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const MAX_RULES = 200;
|
|
14
|
+
// How much of each part a rule may look at. Patterns come from our rules, but
|
|
15
|
+
// the text comes from whoever is asking, and no regex engine we ship to can be
|
|
16
|
+
// timed out: a bounded slice keeps a bad pattern from being a hung request.
|
|
17
|
+
const MAX_TEXT = 2000;
|
|
18
|
+
const MAX_REGEX = 200;
|
|
19
|
+
|
|
20
|
+
const lower = (value) => String(value ?? "").slice(0, MAX_TEXT).toLowerCase();
|
|
21
|
+
// One pass only, as the protocol says: enough for %2e%2e, and no risk of
|
|
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;
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(text.replace(/\+/g, " "));
|
|
27
|
+
} catch {
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const normalizePath = (path) => lower(path).replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
32
|
+
|
|
33
|
+
// A pattern can only come from us, but a careless one would be matched against
|
|
34
|
+
// text an attacker chooses, in this very request, and JavaScript cannot time a
|
|
35
|
+
// 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
|
|
38
|
+
// are refused for the same reason (and they aren't in every engine we ship to).
|
|
39
|
+
function safePattern(pattern) {
|
|
40
|
+
if (typeof pattern !== "string" || !pattern || pattern.length > MAX_REGEX) return false;
|
|
41
|
+
if (/\\[1-9]/.test(pattern) || /\(\?</.test(pattern)) return false;
|
|
42
|
+
const open = [];
|
|
43
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
44
|
+
const char = pattern[i];
|
|
45
|
+
if (char === "\\") {
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (char === "[") {
|
|
50
|
+
// A character class: quantifiers inside it are literals.
|
|
51
|
+
while (i < pattern.length && pattern[i] !== "]") i += pattern[i] === "\\" ? 2 : 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === "(") open.push(i);
|
|
55
|
+
else if (char === ")") {
|
|
56
|
+
const start = open.pop();
|
|
57
|
+
if (start === undefined) return false;
|
|
58
|
+
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;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return open.length === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compileSection(section) {
|
|
69
|
+
if (!section || typeof section !== "object") return null;
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const matcher of ["exact", "prefix", "suffix", "contains"]) {
|
|
72
|
+
const list = Array.isArray(section[matcher]) ? section[matcher].filter((s) => typeof s === "string").map(lower) : [];
|
|
73
|
+
if (list.length) out[matcher] = list;
|
|
74
|
+
}
|
|
75
|
+
if (safePattern(section.regex)) {
|
|
76
|
+
try {
|
|
77
|
+
out.regex = new RegExp(section.regex, "i");
|
|
78
|
+
} catch {
|
|
79
|
+
// A pattern we can't compile is dropped; the rule's other parts stay.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return Object.keys(out).length ? out : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compileRequestRules(data) {
|
|
86
|
+
const given = Array.isArray(data?.rules) ? data.rules.slice(0, MAX_RULES) : [];
|
|
87
|
+
const rules = [];
|
|
88
|
+
for (const rule of given) {
|
|
89
|
+
if (!rule || typeof rule.id !== "string" || !rule.id) continue;
|
|
90
|
+
// The middleware runs before the body is parsed, so we never see it: a
|
|
91
|
+
// rule that needs the body is skipped whole, not matched on the rest.
|
|
92
|
+
if (rule.body) continue;
|
|
93
|
+
const compiled = {
|
|
94
|
+
id: rule.id,
|
|
95
|
+
action: rule.action === "block" ? "block" : "monitor",
|
|
96
|
+
ban: Number(rule.ban) > 0 ? Number(rule.ban) : 0,
|
|
97
|
+
methods: Array.isArray(rule.methods) && rule.methods.length
|
|
98
|
+
? new Set(rule.methods.map((method) => String(method).toUpperCase())) : null,
|
|
99
|
+
path: compileSection(rule.path),
|
|
100
|
+
query: compileSection(rule.query),
|
|
101
|
+
ua: compileSection(rule.ua),
|
|
102
|
+
};
|
|
103
|
+
// A rule with nothing to match would match everything.
|
|
104
|
+
if (!compiled.path && !compiled.query && !compiled.ua) continue;
|
|
105
|
+
rules.push(compiled);
|
|
106
|
+
}
|
|
107
|
+
return rules;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Any one entry of the section, against the text as it came and decoded once.
|
|
111
|
+
function sectionHits(section, raw, decoded) {
|
|
112
|
+
const texts = decoded === raw ? [raw] : [raw, decoded];
|
|
113
|
+
for (const text of texts) {
|
|
114
|
+
if (section.exact?.includes(text)) return true;
|
|
115
|
+
if (section.prefix?.some((entry) => text.startsWith(entry))) return true;
|
|
116
|
+
if (section.suffix?.some((entry) => text.endsWith(entry))) return true;
|
|
117
|
+
if (section.contains?.some((entry) => text.includes(entry))) return true;
|
|
118
|
+
if (section.regex?.test(text)) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The first rule that matches, or null. `path` is the full target as the
|
|
124
|
+
// server received it, query string and all.
|
|
125
|
+
function matchRequest(rules, { method = "", path = "", userAgent = "" } = {}) {
|
|
126
|
+
if (!rules || !rules.length) return null;
|
|
127
|
+
const target = String(path ?? "");
|
|
128
|
+
const mark = target.indexOf("?");
|
|
129
|
+
const rawPath = normalizePath(mark === -1 ? target : target.slice(0, mark));
|
|
130
|
+
const rawQuery = lower(mark === -1 ? "" : target.slice(mark + 1));
|
|
131
|
+
const ua = lower(userAgent);
|
|
132
|
+
const decodedPath = normalizePath(decodeOnce(mark === -1 ? target : target.slice(0, mark)));
|
|
133
|
+
const decodedQuery = decodeOnce(rawQuery);
|
|
134
|
+
const upper = String(method ?? "").toUpperCase();
|
|
135
|
+
for (const rule of rules) {
|
|
136
|
+
if (rule.methods && !rule.methods.has(upper)) continue;
|
|
137
|
+
if (rule.path && !sectionHits(rule.path, rawPath, decodedPath)) continue;
|
|
138
|
+
if (rule.query && !sectionHits(rule.query, rawQuery, decodedQuery)) continue;
|
|
139
|
+
if (rule.ua && !sectionHits(rule.ua, ua, ua)) continue;
|
|
140
|
+
return rule;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { compileRequestRules, matchRequest, safePattern, MAX_RULES, MAX_TEXT };
|