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
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Software inventory module (protocol in sdk/protocols/inventory.md): the
|
|
2
|
+
// packages the app runs (its package-lock.json, production packages only)
|
|
3
|
+
// and its Node version, sent to POST /events/inventory a minute after start
|
|
4
|
+
// and then once a day (the collector's `every`), so the Security check can
|
|
5
|
+
// match them against known security holes. Read in the background, never in
|
|
6
|
+
// a request; names and versions only.
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("node:fs");
|
|
10
|
+
const os = require("node:os");
|
|
11
|
+
const path = require("node:path");
|
|
12
|
+
|
|
13
|
+
const FIRST_MS = 60 * 1000;
|
|
14
|
+
const RETRY_S = 60 * 60;
|
|
15
|
+
const EVERY_S = 24 * 60 * 60;
|
|
16
|
+
const MAX_PACKAGES = 5000;
|
|
17
|
+
const LOCK_FILES = ["package-lock.json", "npm-shrinkwrap.json"];
|
|
18
|
+
|
|
19
|
+
// The packages the app itself asks for, from its package.json: the ones the
|
|
20
|
+
// owner chose, as opposed to the hundreds those depend on. Production only,
|
|
21
|
+
// to match the lock file. An empty set means we couldn't tell.
|
|
22
|
+
function directNames(manifest) {
|
|
23
|
+
const names = new Set();
|
|
24
|
+
for (const key of ["dependencies", "optionalDependencies"]) {
|
|
25
|
+
const deps = manifest?.[key];
|
|
26
|
+
if (deps && typeof deps === "object") for (const name of Object.keys(deps)) names.add(name);
|
|
27
|
+
}
|
|
28
|
+
return names;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A lock file -> [{ ecosystem, name, version }]: lockfile v2/v3 "packages"
|
|
32
|
+
// (the name from the last node_modules/ in the path, or the entry's own for
|
|
33
|
+
// aliases), else v1 "dependencies"; dev packages and links left out.
|
|
34
|
+
// `direct` marks the ones package.json asks for by name.
|
|
35
|
+
function lockPackages(lock, direct = new Set()) {
|
|
36
|
+
const found = new Map();
|
|
37
|
+
const add = (name, version) => {
|
|
38
|
+
if (typeof name !== "string" || !name || typeof version !== "string") return;
|
|
39
|
+
const clean = version.replace(/^v/, "");
|
|
40
|
+
// file:, git and tarball versions aren't releases.
|
|
41
|
+
if (!/^\d/.test(clean)) return;
|
|
42
|
+
const row = { ecosystem: "npm", name, version: clean };
|
|
43
|
+
if (direct.has(name)) row.direct = true;
|
|
44
|
+
found.set(`${name}@${clean}`, row);
|
|
45
|
+
};
|
|
46
|
+
if (lock?.packages && typeof lock.packages === "object") {
|
|
47
|
+
for (const [key, entry] of Object.entries(lock.packages)) {
|
|
48
|
+
const at = key.lastIndexOf("node_modules/");
|
|
49
|
+
// "" is the app itself; paths without node_modules/ are workspaces.
|
|
50
|
+
if (at === -1 || !entry || entry.dev || entry.link) continue;
|
|
51
|
+
add(entry.name || key.slice(at + "node_modules/".length), entry.version);
|
|
52
|
+
}
|
|
53
|
+
} else if (lock?.dependencies && typeof lock.dependencies === "object") {
|
|
54
|
+
const walk = (deps) => {
|
|
55
|
+
for (const [name, entry] of Object.entries(deps)) {
|
|
56
|
+
if (!entry || entry.dev) continue;
|
|
57
|
+
add(name, entry.version);
|
|
58
|
+
if (entry.dependencies && typeof entry.dependencies === "object") walk(entry.dependencies);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
walk(lock.dependencies);
|
|
62
|
+
}
|
|
63
|
+
return [...found.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Options: dir (the folder with package-lock.json; default the working
|
|
67
|
+
// directory), host (the server's name; default the host name).
|
|
68
|
+
function createInventory(core, options = {}) {
|
|
69
|
+
const log = (...args) => core.log("inventory", ...args);
|
|
70
|
+
const dir = String(options.dir || process.cwd());
|
|
71
|
+
const host = String(options.host ?? os.hostname()).replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 100) || "server";
|
|
72
|
+
let timer = null;
|
|
73
|
+
let closed = false;
|
|
74
|
+
|
|
75
|
+
// package.json's own dependencies, for `direct`. Missing or unreadable is
|
|
76
|
+
// not an error: the packages then go up unmarked.
|
|
77
|
+
async function direct() {
|
|
78
|
+
try {
|
|
79
|
+
return directNames(JSON.parse(await fs.promises.readFile(path.join(dir, "package.json"), "utf8")));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT") log("could not read package.json:", error.message);
|
|
82
|
+
return new Set();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function packages() {
|
|
87
|
+
const chosen = await direct();
|
|
88
|
+
for (const name of LOCK_FILES) {
|
|
89
|
+
try {
|
|
90
|
+
return lockPackages(JSON.parse(await fs.promises.readFile(path.join(dir, name), "utf8")), chosen);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code !== "ENOENT") log(`could not read ${name}:`, error.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// No lock file: the Node version alone.
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function report() {
|
|
100
|
+
let wait = RETRY_S;
|
|
101
|
+
try {
|
|
102
|
+
if (core.isOn("inventory")) {
|
|
103
|
+
const list = await packages();
|
|
104
|
+
const res = await core.send("inventory", "/events/inventory", {
|
|
105
|
+
auth: "bearer",
|
|
106
|
+
body: {
|
|
107
|
+
site_id: core.siteId,
|
|
108
|
+
host,
|
|
109
|
+
at: new Date().toISOString(),
|
|
110
|
+
runtime: { node: process.versions.node },
|
|
111
|
+
packages: list.slice(0, MAX_PACKAGES),
|
|
112
|
+
truncated: list.length > MAX_PACKAGES,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (res?.ok) {
|
|
116
|
+
const data = await res.json().catch(() => null);
|
|
117
|
+
wait = Number(data?.every) > 0 ? Math.max(RETRY_S, Number(data.every)) : EVERY_S;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log("could not send the inventory:", error?.message);
|
|
122
|
+
}
|
|
123
|
+
schedule(wait * 1000);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function schedule(ms) {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
timer = setTimeout(report, ms);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (core.valid) schedule(FIRST_MS);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
close() {
|
|
136
|
+
closed = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { createInventory, lockPackages, directNames };
|
package/lib/ip.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Addresses and ranges, with no Node built-ins so the edge runtime (Next.js
|
|
2
|
+
// middleware) can use it too. IPv4 is a number, IPv6 a BigInt; IPv4-mapped
|
|
3
|
+
// IPv6 (::ffff:1.2.3.4) is IPv4.
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const V4 = /^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
7
|
+
const HEX = /^[0-9a-f]{1,4}$/i;
|
|
8
|
+
|
|
9
|
+
function v4(text) {
|
|
10
|
+
let n = 0;
|
|
11
|
+
for (const part of text.split(".")) n = n * 256 + Number(part);
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The 8 groups of an IPv6 address, or null when it isn't one.
|
|
16
|
+
function v6Groups(text) {
|
|
17
|
+
let s = text.split("%")[0];
|
|
18
|
+
const cut = s.lastIndexOf(":");
|
|
19
|
+
if (cut < 0) return null;
|
|
20
|
+
const tail = s.slice(cut + 1);
|
|
21
|
+
if (tail.includes(".")) {
|
|
22
|
+
if (!V4.test(tail)) return null;
|
|
23
|
+
const n = v4(tail);
|
|
24
|
+
s = `${s.slice(0, cut + 1)}${Math.floor(n / 65536).toString(16)}:${(n % 65536).toString(16)}`;
|
|
25
|
+
}
|
|
26
|
+
const halves = s.split("::");
|
|
27
|
+
if (halves.length > 2) return null;
|
|
28
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
29
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
30
|
+
if (![...left, ...right].every((g) => HEX.test(g))) return null;
|
|
31
|
+
if (halves.length === 1) return left.length === 8 ? left : null;
|
|
32
|
+
const fill = 8 - left.length - right.length;
|
|
33
|
+
return fill >= 1 ? [...left, ...Array(fill).fill("0"), ...right] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseIp(text) {
|
|
37
|
+
const s = String(text || "").trim();
|
|
38
|
+
if (V4.test(s)) return { family: 4, n: v4(s) };
|
|
39
|
+
const groups = v6Groups(s);
|
|
40
|
+
if (!groups) return null;
|
|
41
|
+
let n = 0n;
|
|
42
|
+
for (const group of groups) n = (n << 16n) | BigInt(parseInt(group, 16));
|
|
43
|
+
if (n >> 32n === 0xffffn) return { family: 4, n: Number(n & 0xffffffffn) };
|
|
44
|
+
return { family: 6, n };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// "1.2.3.4", "1.2.3.0/24", "2001:db8::/32" -> { family, start, end }, or null.
|
|
48
|
+
function parseRange(entry) {
|
|
49
|
+
const [addr, bits, extra] = String(entry || "").trim().split("/");
|
|
50
|
+
if (extra !== undefined || (bits !== undefined && !/^\d{1,3}$/.test(bits))) return null;
|
|
51
|
+
const ip = parseIp(addr);
|
|
52
|
+
if (!ip) return null;
|
|
53
|
+
const max = ip.family === 4 ? 32 : 128;
|
|
54
|
+
let prefix = bits === undefined ? max : Number(bits);
|
|
55
|
+
if (ip.family === 4 && addr.includes(":") && bits !== undefined) prefix -= 96;
|
|
56
|
+
if (prefix < 0 || prefix > max) return null;
|
|
57
|
+
if (ip.family === 4) {
|
|
58
|
+
const size = 2 ** (32 - prefix);
|
|
59
|
+
const start = Math.floor(ip.n / size) * size;
|
|
60
|
+
return { family: 4, start, end: start + size - 1 };
|
|
61
|
+
}
|
|
62
|
+
const host = (1n << BigInt(128 - prefix)) - 1n;
|
|
63
|
+
return { family: 6, start: ip.n & ~host, end: (ip.n & ~host) | host };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Addresses and ranges, merged and sorted, looked up by binary search.
|
|
67
|
+
class IpSet {
|
|
68
|
+
constructor(entries = []) {
|
|
69
|
+
const ranges = { 4: [], 6: [] };
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const range = parseRange(entry);
|
|
72
|
+
if (range) ranges[range.family].push(range);
|
|
73
|
+
}
|
|
74
|
+
this[4] = IpSet.merge(ranges[4], 1);
|
|
75
|
+
this[6] = IpSet.merge(ranges[6], 1n);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static merge(ranges, one) {
|
|
79
|
+
ranges.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
|
|
80
|
+
const merged = [];
|
|
81
|
+
for (const r of ranges) {
|
|
82
|
+
const last = merged[merged.length - 1];
|
|
83
|
+
if (last && r.start <= last.end + one) {
|
|
84
|
+
if (r.end > last.end) last.end = r.end;
|
|
85
|
+
} else merged.push({ start: r.start, end: r.end });
|
|
86
|
+
}
|
|
87
|
+
return merged;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
has(ip) {
|
|
91
|
+
const list = this[ip.family];
|
|
92
|
+
let lo = 0;
|
|
93
|
+
let hi = list.length - 1;
|
|
94
|
+
while (lo <= hi) {
|
|
95
|
+
const mid = (lo + hi) >> 1;
|
|
96
|
+
if (list[mid].start > ip.n) hi = mid - 1;
|
|
97
|
+
else if (list[mid].end < ip.n) lo = mid + 1;
|
|
98
|
+
else return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { parseIp, parseRange, IpSet };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Maintenance mode module: while it's on (Security → Server package), visitors
|
|
2
|
+
// get a "back soon" page (503, so search engines know it's temporary),
|
|
3
|
+
// except allowed addresses and whoever opened the preview link
|
|
4
|
+
// (?site_preview=<code>, remembered for a day in a cookie).
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const { parseIp, IpSet } = require("./ip.js");
|
|
8
|
+
|
|
9
|
+
const COOKIE = "site_preview";
|
|
10
|
+
const escape = (text) => String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
11
|
+
|
|
12
|
+
function page(message) {
|
|
13
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
14
|
+
<title>Back soon</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;background:#f8fafc;color:#0f172a}
|
|
15
|
+
main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}p{color:#475569;margin:0}
|
|
16
|
+
@media(prefers-color-scheme:dark){body{background:#0f172a;color:#e2e8f0}p{color:#94a3b8}}</style></head>
|
|
17
|
+
<body><main><h1>Back soon</h1><p>${escape(message || "We're doing some maintenance. Please check back in a few minutes.")}</p></main></body></html>`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cookieOf(req, name) {
|
|
21
|
+
const header = String(req.headers?.cookie || "");
|
|
22
|
+
for (const part of header.split(";")) {
|
|
23
|
+
const [k, ...v] = part.trim().split("=");
|
|
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
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
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
|
+
|
|
44
|
+
// clientIp(req): the firewall's way of finding the visitor's address.
|
|
45
|
+
// Options: skip(req) (true lets a request through, e.g. health checks).
|
|
46
|
+
function createMaintenance(core, { clientIp, skip } = {}) {
|
|
47
|
+
let allowFor = null;
|
|
48
|
+
let allowSet = new IpSet([]);
|
|
49
|
+
|
|
50
|
+
function middleware(req, res, next = () => {}) {
|
|
51
|
+
try {
|
|
52
|
+
const m = core.isOn("maintenance") ? core.maintenance() : null;
|
|
53
|
+
if (!m?.on || skip?.(req)) return next();
|
|
54
|
+
if (allowFor !== m.allow) {
|
|
55
|
+
allowFor = m.allow;
|
|
56
|
+
allowSet = new IpSet(m.allow || []);
|
|
57
|
+
}
|
|
58
|
+
const ip = parseIp(clientIp ? clientIp(req) : req.ip || req.socket?.remoteAddress || "");
|
|
59
|
+
if (ip && allowSet.has(ip)) return next();
|
|
60
|
+
if (m.bypass) {
|
|
61
|
+
if (queryOf(req, COOKIE) === m.bypass) {
|
|
62
|
+
res.setHeader("set-cookie", `${COOKIE}=${encodeURIComponent(m.bypass)}; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax`);
|
|
63
|
+
return next();
|
|
64
|
+
}
|
|
65
|
+
if (cookieOf(req, COOKIE) === m.bypass) return next();
|
|
66
|
+
}
|
|
67
|
+
res.statusCode = 503;
|
|
68
|
+
res.setHeader("retry-after", "600");
|
|
69
|
+
res.setHeader("cache-control", "no-store");
|
|
70
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
71
|
+
res.end(page(m.message));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
core.log("maintenance", "check failed, request passed:", error?.message);
|
|
74
|
+
next();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { middleware };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { createMaintenance, page };
|
package/lib/next.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Next.js (and anything else on Vercel or another edge runtime): the firewall
|
|
2
|
+
// in middleware.ts, and server errors from instrumentation.ts onRequestError.
|
|
3
|
+
// No Node built-ins, so it runs in the edge runtime. For events from route
|
|
4
|
+
// handlers and server actions (Node runtime), use the main package.
|
|
5
|
+
//
|
|
6
|
+
// The edge keeps no process between requests the way a server does: each
|
|
7
|
+
// instance keeps the rules in memory while it lives, fetches them when
|
|
8
|
+
// they're older than the rules' sync interval (in the background, with
|
|
9
|
+
// event.waitUntil), and waits up to FIRST_WAIT_MS for them on a cold start.
|
|
10
|
+
// It fails open: no rules, or the collector unreachable, lets requests pass.
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
const { createCore } = require("./core.js");
|
|
14
|
+
const { parseIp, IpSet } = require("./ip.js");
|
|
15
|
+
const { createLocalBans, compileProbes, isProbe } = require("./bans.js");
|
|
16
|
+
const { compileRequestRules, matchRequest } = require("./requestRules.js");
|
|
17
|
+
|
|
18
|
+
const PROTOCOL = 1;
|
|
19
|
+
const MIN_SYNC_S = 30;
|
|
20
|
+
const RETRY_S = 60;
|
|
21
|
+
const FIRST_WAIT_MS = 1500;
|
|
22
|
+
const FLUSH_MS = 5000;
|
|
23
|
+
const BATCH = 200;
|
|
24
|
+
const QUEUE_MAX = 2000;
|
|
25
|
+
const COUNTER_MAX = 50000;
|
|
26
|
+
|
|
27
|
+
const env = (name) => (typeof process !== "undefined" && process.env ? process.env[name] : undefined);
|
|
28
|
+
|
|
29
|
+
function headerOf(request, name) {
|
|
30
|
+
const headers = request.headers;
|
|
31
|
+
const value = typeof headers?.get === "function" ? headers.get(name) : headers?.[name];
|
|
32
|
+
return String(Array.isArray(value) ? value[0] : value || "").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// trustProxy: "vercel" (default on Vercel: its x-real-ip), "cloudflare"
|
|
36
|
+
// (CF-Connecting-IP), a number of proxies in X-Forwarded-For, or a function.
|
|
37
|
+
// Self-hosted Next.js has no connection address in middleware: set one.
|
|
38
|
+
function clientIp(request, trustProxy) {
|
|
39
|
+
if (typeof trustProxy === "function") return String(trustProxy(request) || "");
|
|
40
|
+
if (trustProxy === "cloudflare") return headerOf(request, "cf-connecting-ip");
|
|
41
|
+
if (Number.isInteger(trustProxy) && trustProxy > 0) {
|
|
42
|
+
const chain = headerOf(request, "x-forwarded-for").split(",").map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
return chain[Math.max(0, chain.length - trustProxy)] || "";
|
|
44
|
+
}
|
|
45
|
+
if (trustProxy === "vercel" || (trustProxy === undefined && env("VERCEL"))) {
|
|
46
|
+
return request.ip || headerOf(request, "x-real-ip") || headerOf(request, "x-forwarded-for").split(",")[0].trim();
|
|
47
|
+
}
|
|
48
|
+
return request.ip || "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compile(data) {
|
|
52
|
+
if (!data || data.v !== PROTOCOL) throw new Error(`unknown rules version ${data?.v}`);
|
|
53
|
+
return {
|
|
54
|
+
enabled: data.enabled === true,
|
|
55
|
+
mode: data.mode === "block" ? "block" : "monitor",
|
|
56
|
+
sync: Math.max(MIN_SYNC_S, Number(data.sync) || RETRY_S),
|
|
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),
|
|
60
|
+
deny: new IpSet(data.deny),
|
|
61
|
+
list: new IpSet(data.lists),
|
|
62
|
+
shared: new IpSet(data.shared),
|
|
63
|
+
rate: data.rate && Number(data.rate.limit) > 0
|
|
64
|
+
? { limit: Number(data.rate.limit), window: Math.max(1, Number(data.rate.window) || 60) }
|
|
65
|
+
: null,
|
|
66
|
+
banned: new IpSet(data.banned),
|
|
67
|
+
ban: data.ban && Number(data.ban.seconds) > 0 ? { seconds: Number(data.ban.seconds) } : null,
|
|
68
|
+
probes: compileProbes(data.probes),
|
|
69
|
+
// Virtual patching: rules that match the request, not the address.
|
|
70
|
+
requests: compileRequestRules(data.requests),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Options: siteId, secret, endpoint; trustProxy (see clientIp); mode
|
|
75
|
+
// ("monitor" / "block": overrides the dashboard); skip(request); counter
|
|
76
|
+
// (async (key, ttlSeconds) => hits, shared between instances, e.g. Upstash
|
|
77
|
+
// Redis: each instance counts on its own otherwise); debug.
|
|
78
|
+
// Returns middleware(request, event): a Response when blocked, else undefined.
|
|
79
|
+
function createNextFirewall(options = {}) {
|
|
80
|
+
const core = createCore({ ...options, client: "next" });
|
|
81
|
+
if (!core.valid) console.warn("[analytics-platform] siteId, secret and endpoint are required; every request passes");
|
|
82
|
+
let rules = null;
|
|
83
|
+
let etag = "";
|
|
84
|
+
let fetchedAt = 0;
|
|
85
|
+
let syncing = null;
|
|
86
|
+
let queue = new Map();
|
|
87
|
+
// The first report goes straight away (an edge instance may not live 5 seconds), then at most every FLUSH_MS.
|
|
88
|
+
let lastFlush = 0;
|
|
89
|
+
const counts = new Map();
|
|
90
|
+
const local = createLocalBans();
|
|
91
|
+
|
|
92
|
+
async function sync() {
|
|
93
|
+
try {
|
|
94
|
+
const res = await core.send("firewall", `/events/firewall/rules?site_id=${encodeURIComponent(core.siteId)}`, {
|
|
95
|
+
method: "GET", auth: "bearer", headers: etag ? { "if-none-match": etag } : {},
|
|
96
|
+
});
|
|
97
|
+
if (res?.status === 200) {
|
|
98
|
+
rules = compile(await res.json());
|
|
99
|
+
etag = res.headers.get("etag") || "";
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
core.log("firewall", "rules not used:", error?.message);
|
|
103
|
+
}
|
|
104
|
+
fetchedAt = Date.now();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function refresh(event) {
|
|
108
|
+
const due = Date.now() - fetchedAt >= (rules?.sync || RETRY_S) * 1000;
|
|
109
|
+
if (!core.valid || !due || syncing) return syncing;
|
|
110
|
+
syncing = sync().finally(() => { syncing = null; });
|
|
111
|
+
event?.waitUntil?.(syncing);
|
|
112
|
+
return syncing;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function flush() {
|
|
116
|
+
if (!queue.size) return;
|
|
117
|
+
const rows = [...queue.values()];
|
|
118
|
+
queue = new Map();
|
|
119
|
+
lastFlush = Date.now();
|
|
120
|
+
for (let i = 0; i < rows.length; i += BATCH) {
|
|
121
|
+
await core.send("firewall", "/events/firewall/events", { auth: "bearer", body: { site_id: core.siteId, events: rows.slice(i, i + BATCH) } });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function overRate(ip, rate) {
|
|
126
|
+
const window = Math.floor(Date.now() / 1000 / rate.window);
|
|
127
|
+
if (typeof options.counter === "function") {
|
|
128
|
+
return Number(await options.counter(`afw:${core.siteId}:${ip}:${window}`, rate.window * 2)) > rate.limit;
|
|
129
|
+
}
|
|
130
|
+
let entry = counts.get(ip);
|
|
131
|
+
if (!entry || entry.window !== window) {
|
|
132
|
+
if (!entry && counts.size >= COUNTER_MAX) counts.clear();
|
|
133
|
+
entry = { window, hits: 0 };
|
|
134
|
+
counts.set(ip, entry);
|
|
135
|
+
}
|
|
136
|
+
entry.hits += 1;
|
|
137
|
+
return entry.hits > rate.limit;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return async function analyticsFirewall(request, event) {
|
|
141
|
+
try {
|
|
142
|
+
if (!core.valid || options.skip?.(request)) return undefined;
|
|
143
|
+
const pending = refresh(event);
|
|
144
|
+
// Cold start: wait a moment for the first rules.
|
|
145
|
+
if (!rules && pending) await Promise.race([pending, new Promise((resolve) => setTimeout(resolve, FIRST_WAIT_MS))]);
|
|
146
|
+
const current = rules;
|
|
147
|
+
if (!current?.enabled) return undefined;
|
|
148
|
+
const ip = clientIp(request, options.trustProxy);
|
|
149
|
+
const address = parseIp(ip);
|
|
150
|
+
if (!address || current.allow.has(address)) return undefined;
|
|
151
|
+
const mode = options.mode === "block" || options.mode === "monitor" ? options.mode : current.mode;
|
|
152
|
+
const action = mode === "block" ? "block" : "monitor";
|
|
153
|
+
// A request rule may only watch, even when the site blocks.
|
|
154
|
+
let effective = action;
|
|
155
|
+
const path = request.nextUrl?.pathname || new URL(request.url).pathname;
|
|
156
|
+
// The rules match the query string too, which the path alone leaves out.
|
|
157
|
+
const target = (request.nextUrl?.pathname || "") + (request.nextUrl?.search || "")
|
|
158
|
+
|| new URL(request.url).pathname + new URL(request.url).search;
|
|
159
|
+
let rule = current.deny.has(address) ? "deny"
|
|
160
|
+
: current.banned.has(address) || local.banned(ip) ? "ban"
|
|
161
|
+
: current.list.has(address) ? "list" : current.shared.has(address) ? "shared" : "";
|
|
162
|
+
if (!rule && isProbe(path, current.probes)) {
|
|
163
|
+
rule = "probe";
|
|
164
|
+
if (action === "block") local.ban(ip, current.probes.ban);
|
|
165
|
+
}
|
|
166
|
+
if (!rule) {
|
|
167
|
+
const matched = matchRequest(current.requests, { method: request.method, path: target, userAgent: headerOf(request, "user-agent") });
|
|
168
|
+
if (matched) {
|
|
169
|
+
rule = `request:${matched.id}`;
|
|
170
|
+
effective = action === "block" && matched.action === "block" ? "block" : "monitor";
|
|
171
|
+
if (effective === "block" && matched.ban) local.ban(ip, matched.ban);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!rule && current.rate && !current.goodbots.has(address) && await overRate(ip, current.rate)) {
|
|
175
|
+
rule = "rate";
|
|
176
|
+
if (current.ban) local.ban(ip, current.ban.seconds);
|
|
177
|
+
}
|
|
178
|
+
if (!rule) return undefined;
|
|
179
|
+
const method = String(request.method || "").slice(0, 10);
|
|
180
|
+
const key = `${ip}|${rule}|${effective}|${method}|${path}`;
|
|
181
|
+
const row = queue.get(key);
|
|
182
|
+
if (row) row.hits += 1;
|
|
183
|
+
else if (queue.size < QUEUE_MAX) {
|
|
184
|
+
queue.set(key, { at: new Date().toISOString(), ip, rule, action: effective, method, path: path.slice(0, 300), user_agent: headerOf(request, "user-agent").slice(0, 300), hits: 1 });
|
|
185
|
+
}
|
|
186
|
+
if (queue.size >= BATCH || Date.now() - lastFlush >= FLUSH_MS) event?.waitUntil?.(flush());
|
|
187
|
+
if (effective !== "block") return undefined;
|
|
188
|
+
const status = rule === "rate" ? 429 : 403;
|
|
189
|
+
return new Response(status === 429 ? "Too Many Requests" : "Forbidden", {
|
|
190
|
+
status,
|
|
191
|
+
headers: {
|
|
192
|
+
"content-type": "text/plain; charset=utf-8",
|
|
193
|
+
"cache-control": "no-store",
|
|
194
|
+
...(status === 429 ? { "retry-after": String(current.rate.window) } : {}),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
core.log("firewall", "check failed, request passed:", error?.message);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// instrumentation.ts: export const onRequestError = createOnRequestError({...}).
|
|
205
|
+
// Sends each server error (pages, route handlers, server actions, middleware)
|
|
206
|
+
// to the Errors report, unless the dashboard switched error monitoring off.
|
|
207
|
+
function createOnRequestError(options = {}) {
|
|
208
|
+
const core = createCore({ ...options, client: "next" });
|
|
209
|
+
let switches = { at: 0, errors: true };
|
|
210
|
+
|
|
211
|
+
async function errorsOn() {
|
|
212
|
+
if (Date.now() - switches.at < RETRY_S * 1000) return switches.errors;
|
|
213
|
+
const res = await core.send("errors", `/events/platform/config?site_id=${encodeURIComponent(core.siteId)}`, {
|
|
214
|
+
method: "GET", auth: "bearer", headers: { "x-platform-modules": "errors" },
|
|
215
|
+
});
|
|
216
|
+
const data = res?.ok ? await res.json().catch(() => null) : null;
|
|
217
|
+
switches = { at: Date.now(), errors: data?.modules?.errors !== false };
|
|
218
|
+
return switches.errors;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return async function onRequestError(error, request, context) {
|
|
222
|
+
try {
|
|
223
|
+
if (!core.valid || !(await errorsOn())) return;
|
|
224
|
+
const headers = request?.headers || {};
|
|
225
|
+
const session = headers["x-analytics-session"];
|
|
226
|
+
await core.send("errors", "/events/server-errors", {
|
|
227
|
+
body: {
|
|
228
|
+
errors: [{
|
|
229
|
+
message: String(error?.message || error || "Unknown error"),
|
|
230
|
+
error_type: error?.name || "Error",
|
|
231
|
+
stack: typeof error?.stack === "string" ? error.stack : "",
|
|
232
|
+
kind: "error",
|
|
233
|
+
status_code: 500,
|
|
234
|
+
runtime: "Next.js",
|
|
235
|
+
runtime_version: env("NEXT_RUNTIME") || "",
|
|
236
|
+
os: "",
|
|
237
|
+
server_name: env("VERCEL_REGION") || env("HOSTNAME") || "",
|
|
238
|
+
method: String(request?.method || ""),
|
|
239
|
+
path: String(request?.path || "").split("?")[0],
|
|
240
|
+
route: String(context?.routePath || ""),
|
|
241
|
+
session_id: typeof session === "string" ? session : "",
|
|
242
|
+
}],
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
} catch {
|
|
246
|
+
// never throw from the error hook
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { createNextFirewall, createOnRequestError, clientIp };
|