@camada/node 0.3.1 → 0.4.0
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 +72 -6
- package/dist/{camada-Dh_9D6Sx.d.cts → camada-DuHQ4sLn.d.cts} +46 -4
- package/dist/{camada-Dh_9D6Sx.d.ts → camada-DuHQ4sLn.d.ts} +46 -4
- package/dist/{chunk-PNJ4BVBD.js → chunk-44FSZJ5F.js} +280 -87
- package/dist/chunk-44FSZJ5F.js.map +1 -0
- package/dist/fastify.cjs +275 -82
- package/dist/fastify.cjs.map +1 -1
- package/dist/fastify.d.cts +1 -1
- package/dist/fastify.d.ts +1 -1
- package/dist/fastify.js +1 -1
- package/dist/index.cjs +275 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/koa.cjs +277 -82
- package/dist/koa.cjs.map +1 -1
- package/dist/koa.d.cts +9 -2
- package/dist/koa.d.ts +9 -2
- package/dist/koa.js +3 -1
- package/dist/koa.js.map +1 -1
- package/dist/nest.cjs +275 -82
- package/dist/nest.cjs.map +1 -1
- package/dist/nest.d.cts +1 -1
- package/dist/nest.d.ts +1 -1
- package/dist/nest.js +1 -1
- package/package.json +48 -15
- package/dist/chunk-PNJ4BVBD.js.map +0 -1
package/dist/fastify.cjs
CHANGED
|
@@ -37,10 +37,15 @@ module.exports = __toCommonJS(fastify_exports);
|
|
|
37
37
|
// src/camada.ts
|
|
38
38
|
var import_node_crypto2 = require("crypto");
|
|
39
39
|
var import_core3 = require("@camada/core");
|
|
40
|
+
var import_node_net = require("net");
|
|
40
41
|
var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
|
|
41
42
|
|
|
42
43
|
// src/env.ts
|
|
43
44
|
var import_core = require("@camada/core");
|
|
45
|
+
function parseTlsxHeader(raw) {
|
|
46
|
+
const n = (raw ?? "").trim().toLowerCase();
|
|
47
|
+
return /^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(n) ? n : null;
|
|
48
|
+
}
|
|
44
49
|
function resolveEnv(env = process.env) {
|
|
45
50
|
const key = (0, import_core.parseKey)(env.CAMADA_KEY);
|
|
46
51
|
const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;
|
|
@@ -54,7 +59,8 @@ function resolveEnv(env = process.env) {
|
|
|
54
59
|
secret: env.CAMADA_KEY || `${ingestToken}.${snapToken}`,
|
|
55
60
|
snapshotUrl: env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,
|
|
56
61
|
serverless: env.CAMADA_SERVERLESS === "1",
|
|
57
|
-
trustedProxy: (0, import_core.parseTrustedProxyEnv)(env.CAMADA_TRUSTED_PROXY)
|
|
62
|
+
trustedProxy: (0, import_core.parseTrustedProxyEnv)(env.CAMADA_TRUSTED_PROXY),
|
|
63
|
+
tlsxHeader: parseTlsxHeader(env.CAMADA_TLSX_HEADER)
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -86,18 +92,45 @@ function readBody(req, done) {
|
|
|
86
92
|
req.on("end", () => done(dead ? "" : Buffer.concat(chunks).toString("utf8")));
|
|
87
93
|
req.on("error", () => done(""));
|
|
88
94
|
}
|
|
89
|
-
var
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
var FORM_FIELDS = ["nonce", "solution", "to"];
|
|
96
|
+
function parsedBody(req) {
|
|
97
|
+
const b = req.body;
|
|
98
|
+
if (typeof b === "string") return b;
|
|
99
|
+
if (Buffer.isBuffer(b)) return b.toString("utf8");
|
|
100
|
+
return b && typeof b === "object" ? b : null;
|
|
101
|
+
}
|
|
102
|
+
function readForm(req, done) {
|
|
103
|
+
if (!req.readableEnded) {
|
|
104
|
+
readBody(req, (body) => done((0, import_core2.parseFormBody)(body)));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const b = parsedBody(req);
|
|
108
|
+
if (typeof b === "string") {
|
|
109
|
+
done(b.length > BODY_MAX ? {} : (0, import_core2.parseFormBody)(b));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const form = /* @__PURE__ */ Object.create(null);
|
|
113
|
+
for (const k of FORM_FIELDS) {
|
|
114
|
+
const v = b?.[k];
|
|
115
|
+
if (typeof v === "string") form[k] = v;
|
|
116
|
+
}
|
|
117
|
+
done(form);
|
|
93
118
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
res.
|
|
119
|
+
var isHttps = (req) => !!req.socket?.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
120
|
+
function writeChallengePage(res, o) {
|
|
121
|
+
res.removeHeader("content-security-policy");
|
|
122
|
+
res.removeHeader("content-security-policy-report-only");
|
|
123
|
+
res.writeHead(403, {
|
|
124
|
+
"content-type": "text/html; charset=utf-8",
|
|
125
|
+
"cache-control": "no-store",
|
|
126
|
+
"x-camada-challenge": "1",
|
|
127
|
+
"content-security-policy": (0, import_core2.challengePageCsp)(o.bits)
|
|
128
|
+
});
|
|
129
|
+
res.end((0, import_core2.challengePage)(o));
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
// package.json
|
|
100
|
-
var version = "0.
|
|
133
|
+
var version = "0.4.0";
|
|
101
134
|
var name = "@camada/node";
|
|
102
135
|
|
|
103
136
|
// src/version.ts
|
|
@@ -108,11 +141,40 @@ var SESSION_COOKIE = "_sfp";
|
|
|
108
141
|
var SCRIPT_PATH = "/_cam/b.js";
|
|
109
142
|
var FP_PATH = "/_cam/fp";
|
|
110
143
|
var FP_MAX = 32 * 1024;
|
|
144
|
+
function isLoopbackPeer(peer) {
|
|
145
|
+
const k = (0, import_node_net.isIP)(peer);
|
|
146
|
+
if (k === 4) return peer.startsWith("127.");
|
|
147
|
+
return k !== 6 || peer === "::1" || /^::ffff:127\./i.test(peer);
|
|
148
|
+
}
|
|
149
|
+
var TLSX_RE = /^[A-Za-z0-9+/=_-]+$/;
|
|
111
150
|
var ATTACHED = /* @__PURE__ */ Symbol.for("camada.attached");
|
|
112
151
|
var headerReader = (req) => (name2) => {
|
|
113
152
|
const v = req.headers[name2];
|
|
114
153
|
return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
|
|
115
154
|
};
|
|
155
|
+
var splitUrl = (raw) => {
|
|
156
|
+
const qi = raw.indexOf("?");
|
|
157
|
+
return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
|
|
158
|
+
};
|
|
159
|
+
var isPage = (req) => (0, import_core3.wantsHtml)(req.headers.accept ?? null, req.headers["sec-fetch-dest"] ?? null);
|
|
160
|
+
function pathAndQuery(req) {
|
|
161
|
+
return splitUrl(req.originalUrl || req.url || "/");
|
|
162
|
+
}
|
|
163
|
+
var atEndpoint = (req, path, endpoint) => path === endpoint || splitUrl(req.url || "/").path === endpoint;
|
|
164
|
+
function mountPrefix(req) {
|
|
165
|
+
if (!req.originalUrl) return "";
|
|
166
|
+
const full = splitUrl(req.originalUrl).path, rel = splitUrl(req.url || "/").path;
|
|
167
|
+
return rel !== "/" && full !== rel && full.endsWith(rel) ? full.slice(0, full.length - rel.length) : "";
|
|
168
|
+
}
|
|
169
|
+
function onUnixListener(socket) {
|
|
170
|
+
const srv = socket?.server;
|
|
171
|
+
if (!srv) return false;
|
|
172
|
+
try {
|
|
173
|
+
if (typeof srv.address?.() === "string") return true;
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
return typeof srv._pipeName === "string";
|
|
177
|
+
}
|
|
116
178
|
var cookieValue = (cookie, name2) => {
|
|
117
179
|
const src = "; " + cookie;
|
|
118
180
|
const i = src.indexOf("; " + name2 + "=");
|
|
@@ -132,6 +194,27 @@ function keepSessionCookie(res, cookie) {
|
|
|
132
194
|
};
|
|
133
195
|
res.setHeader("set-cookie", cookie);
|
|
134
196
|
}
|
|
197
|
+
var NO_STORE_HEADERS = ["cache-control", "cdn-cache-control", "surrogate-control", "cloudflare-cdn-cache-control", "vercel-cdn-cache-control", "netlify-cdn-cache-control"];
|
|
198
|
+
function signal(res) {
|
|
199
|
+
const set = res.setHeader, writeHead = res.writeHead;
|
|
200
|
+
const isNoStore = (n) => typeof n === "string" && NO_STORE_HEADERS.includes(n.toLowerCase());
|
|
201
|
+
const strip = (h) => {
|
|
202
|
+
if (Array.isArray(h)) {
|
|
203
|
+
if (h.length && Array.isArray(h[0])) return h.filter((p) => !isNoStore(p[0]));
|
|
204
|
+
const out = [];
|
|
205
|
+
for (let i = 0; i < h.length; i += 2) if (!isNoStore(h[i])) out.push(h[i], h[i + 1]);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
if (h && typeof h === "object") return Object.fromEntries(Object.entries(h).filter(([k]) => !isNoStore(k)));
|
|
209
|
+
return h;
|
|
210
|
+
};
|
|
211
|
+
res.writeHead = function(status, ...rest) {
|
|
212
|
+
if (!this.headersSent) for (const h of NO_STORE_HEADERS) set.call(this, h, "no-store");
|
|
213
|
+
return writeHead.call(this, status, ...rest.map(strip));
|
|
214
|
+
};
|
|
215
|
+
res.setHeader("x-camada-challenge", "1");
|
|
216
|
+
for (const h of NO_STORE_HEADERS) res.setHeader(h, "no-store");
|
|
217
|
+
}
|
|
135
218
|
var Camada = class {
|
|
136
219
|
env;
|
|
137
220
|
snap = null;
|
|
@@ -141,16 +224,25 @@ var Camada = class {
|
|
|
141
224
|
fpPath;
|
|
142
225
|
challengeOn;
|
|
143
226
|
challengePath;
|
|
227
|
+
challengePathSet;
|
|
228
|
+
// configured explicitly: used as given, never prefixed
|
|
229
|
+
scriptPathSet;
|
|
144
230
|
kit = null;
|
|
145
231
|
envSource;
|
|
232
|
+
tlsxHeader;
|
|
233
|
+
waitUntil;
|
|
146
234
|
constructor(opts = {}) {
|
|
147
235
|
this.envSource = opts.env ?? process.env;
|
|
148
236
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
237
|
+
this.waitUntil = opts.waitUntil;
|
|
149
238
|
this.scriptPath = opts.scriptPath ?? SCRIPT_PATH;
|
|
150
239
|
this.fpPath = opts.fpPath ?? FP_PATH;
|
|
151
240
|
this.challengeOn = opts.challenge !== false && this.envSource.CAMADA_CHALLENGE !== "0";
|
|
152
241
|
this.challengePath = opts.challengePath ?? CHALLENGE_PATH;
|
|
242
|
+
this.challengePathSet = opts.challengePath != null;
|
|
243
|
+
this.scriptPathSet = opts.scriptPath != null;
|
|
153
244
|
this.env = resolveEnv(this.envSource);
|
|
245
|
+
this.tlsxHeader = parseTlsxHeader(opts.tlsxHeader) ?? this.env?.tlsxHeader ?? null;
|
|
154
246
|
if (!this.env) return;
|
|
155
247
|
if (this.envSource.CAMADA_DISABLED === "1") return;
|
|
156
248
|
this.snap = new import_core3.SnapshotClient({
|
|
@@ -173,9 +265,61 @@ var Camada = class {
|
|
|
173
265
|
trustedProxy() {
|
|
174
266
|
return this.env?.trustedProxy ?? this.snap?.config?.trusted_proxy ?? null;
|
|
175
267
|
}
|
|
268
|
+
/** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
|
|
269
|
+
* unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
|
|
270
|
+
* X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
|
|
271
|
+
* client reset the connection before camada ran, e.g. behind an async middleware) is
|
|
272
|
+
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
273
|
+
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
274
|
+
clientIp(req) {
|
|
275
|
+
const peer = req.socket?.remoteAddress;
|
|
276
|
+
if (!peer && req.socket && !onUnixListener(req.socket)) return null;
|
|
277
|
+
return (0, import_core3.resolveClientIp)(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
|
|
278
|
+
}
|
|
279
|
+
/** The TLS fingerprint Cloudflare (a Transform Rule) put in the opt-in header, or null. Trusted only
|
|
280
|
+
* in `cidrs` mode and only when the socket peer itself is a NON-loopback address core's cidrs walk
|
|
281
|
+
* trusts, i.e. Cloudflare connects to the app directly. A loopback peer (a local reverse proxy or
|
|
282
|
+
* cloudflared) is never trusted and X-Forwarded-For is never consulted, so the header is not read
|
|
283
|
+
* and tlsx conditions fail open. Trust is core's isTrustedPeer, the same first-hop test the cidrs
|
|
284
|
+
* walk uses. No readable peer (unix listener included) -> null, since core's no-peer walk would
|
|
285
|
+
* trust everyone. */
|
|
286
|
+
tlsxOf(req) {
|
|
287
|
+
const header = this.tlsxHeader;
|
|
288
|
+
if (!header) return null;
|
|
289
|
+
const v = headerReader(req)(header);
|
|
290
|
+
if (v == null || v.length > 128 || !TLSX_RE.test(v)) return null;
|
|
291
|
+
const cfg = this.trustedProxy();
|
|
292
|
+
const peer = req.socket?.remoteAddress;
|
|
293
|
+
if (cfg?.mode !== "cidrs" || !peer) return null;
|
|
294
|
+
if (isLoopbackPeer(peer)) return null;
|
|
295
|
+
return (0, import_core3.isTrustedPeer)(peer, cfg) ? v : null;
|
|
296
|
+
}
|
|
297
|
+
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
298
|
+
* path was configured explicitly. */
|
|
299
|
+
challengeAction(req) {
|
|
300
|
+
return this.challengePathSet ? this.challengePath : (req.camadaPrefix ?? mountPrefix(req)) + this.challengePath;
|
|
301
|
+
}
|
|
176
302
|
beaconEnabled() {
|
|
177
303
|
return this.snap?.config?.beacon !== false;
|
|
178
304
|
}
|
|
305
|
+
/** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
|
|
306
|
+
* process once the response is out, so the 15 s interval would never run. Never throws. */
|
|
307
|
+
enqueue(row, req) {
|
|
308
|
+
const q = this.queue;
|
|
309
|
+
q.push(row);
|
|
310
|
+
if (!this.env?.serverless) return;
|
|
311
|
+
if (req?.camadaOpen) return;
|
|
312
|
+
this.flushFor(req);
|
|
313
|
+
}
|
|
314
|
+
/** Serverless: start the POST now and hand it to waitUntil. Never throws. */
|
|
315
|
+
flushFor(req) {
|
|
316
|
+
const q = this.queue;
|
|
317
|
+
(0, import_core3.guarded)(() => {
|
|
318
|
+
const tracks = req?.camadaTracks ?? [];
|
|
319
|
+
const p = Promise.allSettled(tracks).then(() => q.flush()).then(() => q.size > 0 ? q.flush() : void 0);
|
|
320
|
+
this.waitUntil?.(p);
|
|
321
|
+
}, void 0);
|
|
322
|
+
}
|
|
179
323
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
180
324
|
handle(req, res) {
|
|
181
325
|
return (0, import_core3.guarded)(() => this.handleInner(req, res), false);
|
|
@@ -183,15 +327,13 @@ var Camada = class {
|
|
|
183
327
|
handleInner(req, res) {
|
|
184
328
|
if (this.disabled || !this.snap || !this.queue || !this.env) return false;
|
|
185
329
|
const t0 = Date.now();
|
|
186
|
-
this.snap.ensureFresh();
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
const ip = (0, import_core3.resolveClientIp)(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
|
|
192
|
-
const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
|
|
330
|
+
this.snap.ensureFresh(this.env.serverless ? this.waitUntil : void 0);
|
|
331
|
+
const { path, query } = pathAndQuery(req);
|
|
332
|
+
const ip = this.clientIp(req);
|
|
333
|
+
req.camadaPrefix = mountPrefix(req);
|
|
334
|
+
const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req), tlsx: this.tlsxOf(req) });
|
|
193
335
|
if (v.block) {
|
|
194
|
-
const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain" };
|
|
336
|
+
const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
|
|
195
337
|
if (v.rule) headers["x-block-rule"] = v.rule;
|
|
196
338
|
res.writeHead(403, headers);
|
|
197
339
|
res.end("Forbidden");
|
|
@@ -199,73 +341,122 @@ var Camada = class {
|
|
|
199
341
|
ev.st = 403;
|
|
200
342
|
ev.blk = v.reason;
|
|
201
343
|
if (v.rule) ev.rl = v.rule;
|
|
202
|
-
this.
|
|
344
|
+
this.enqueue(ev);
|
|
203
345
|
return true;
|
|
204
346
|
}
|
|
205
347
|
if (this.challengeOn && this.kit && ip) {
|
|
206
|
-
if (req.method === "POST" && path
|
|
348
|
+
if (req.method === "POST" && atEndpoint(req, path, this.challengePath)) {
|
|
207
349
|
this.verifyChallenge(req, res, ip);
|
|
208
350
|
return true;
|
|
209
351
|
}
|
|
210
352
|
if (v.challenge && !this.challengePassed(req, ip)) {
|
|
211
|
-
|
|
212
|
-
|
|
353
|
+
if (isPage(req)) {
|
|
354
|
+
this.serveChallengeInner(req, res, ip, path + query);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
req.camadaSignalled = true;
|
|
213
358
|
}
|
|
214
359
|
}
|
|
215
360
|
if (this.beaconEnabled()) {
|
|
216
|
-
if (req.method === "GET" && path
|
|
361
|
+
if (req.method === "GET" && atEndpoint(req, path, this.scriptPath)) {
|
|
217
362
|
res.writeHead(200, { "content-type": "application/javascript", "cache-control": "public, max-age=3600" });
|
|
218
363
|
res.end(import_iife_string.default);
|
|
219
364
|
return true;
|
|
220
365
|
}
|
|
221
|
-
if (req.method === "POST" && path
|
|
366
|
+
if (req.method === "POST" && atEndpoint(req, path, this.fpPath)) {
|
|
222
367
|
this.relayBeacon(req, res, ip);
|
|
223
368
|
return true;
|
|
224
369
|
}
|
|
225
370
|
}
|
|
371
|
+
const { rid, sid, ns: newSession } = this.begin(req, res, ip, t0, v.warn ? v.rule : void 0);
|
|
372
|
+
if (req.camadaSignalled) signal(res);
|
|
373
|
+
const cfg = this.snap.config;
|
|
374
|
+
const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
|
|
375
|
+
if (req.camadaSignalled || !excluded && Math.random() < (cfg?.sample ?? 1)) {
|
|
376
|
+
this.hookCapture(req, res, path, query, ip, { rid, sid, newSession, t0 });
|
|
377
|
+
}
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
/** Mints the request's rid and session (setting the cookie on a first visit), records them on
|
|
381
|
+
* `req.camada` and sets `x-rid`. handle() and serveChallenge() (when handle() did not run) share it. */
|
|
382
|
+
begin(req, res, ip, t0, wrn) {
|
|
226
383
|
const rid = (0, import_node_crypto2.randomUUID)();
|
|
227
384
|
let sid = cookieValue(req.headers.cookie || "", SESSION_COOKIE);
|
|
228
|
-
const
|
|
385
|
+
const ns = !sid;
|
|
229
386
|
if (!sid) {
|
|
230
387
|
sid = (0, import_node_crypto2.randomUUID)();
|
|
231
388
|
const https = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
232
389
|
keepSessionCookie(res, `${SESSION_COOKIE}=${sid}; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`);
|
|
233
390
|
}
|
|
234
|
-
req.camada = { rid, ip, sid };
|
|
235
391
|
res.setHeader("x-rid", rid);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
res.
|
|
253
|
-
|
|
254
|
-
|
|
392
|
+
return req.camada = { rid, ip, sid, ns, t0, wrn };
|
|
393
|
+
}
|
|
394
|
+
/** Installs the response-finish hook that ships the request's normal capture event. */
|
|
395
|
+
hookCapture(req, res, path, query, ip, o) {
|
|
396
|
+
let shipped = false;
|
|
397
|
+
req.camadaOpen = true;
|
|
398
|
+
const ship = () => (0, import_core3.guarded)(() => {
|
|
399
|
+
req.camadaOpen = false;
|
|
400
|
+
if (shipped) return;
|
|
401
|
+
shipped = true;
|
|
402
|
+
if (req.camadaChallenged) {
|
|
403
|
+
if (this.env?.serverless) this.flushFor(req);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const ev = this.buildEvent(req, path, query, ip, o);
|
|
407
|
+
ev.ts = o.t0;
|
|
408
|
+
ev.st = res.statusCode;
|
|
409
|
+
ev.dur = Date.now() - o.t0;
|
|
410
|
+
if (req.route?.path) ev.rt = String(req.route.path);
|
|
411
|
+
if (req.camada?.wrn) ev.wrn = req.camada.wrn;
|
|
412
|
+
if (req.camadaSignalled) ev.chs = 1;
|
|
413
|
+
this.enqueue(ev, req);
|
|
414
|
+
}, void 0);
|
|
415
|
+
res.once("finish", ship);
|
|
416
|
+
res.once("close", ship);
|
|
255
417
|
}
|
|
256
418
|
buildEvent(req, path, query, ip, o) {
|
|
257
419
|
const raw = req.rawHeaders || [];
|
|
258
420
|
const headers = [];
|
|
259
421
|
for (let i = 0; i + 1 < raw.length; i += 2) headers.push([raw[i], raw[i + 1]]);
|
|
260
|
-
|
|
422
|
+
const ev = (0, import_core3.buildWireEvent)(
|
|
261
423
|
{ method: req.method || "GET", host: req.headers.host || "", path, query, headers, ip, httpVersion: req.httpVersion },
|
|
262
424
|
{ tap: import_core3.TAP_NODE, rid: o.rid, sid: o.sid, newSession: o.newSession }
|
|
263
425
|
);
|
|
426
|
+
const tlsx = this.tlsxOf(req);
|
|
427
|
+
if (tlsx) ev.tlsx = tlsx;
|
|
428
|
+
return ev;
|
|
264
429
|
}
|
|
265
430
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
266
431
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
267
|
-
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
432
|
+
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
433
|
+
* A body parser mounted before camada (express.json(), always on Nest) has already drained the
|
|
434
|
+
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
268
435
|
relayBeacon(req, res, ip) {
|
|
436
|
+
const accept = (parsed) => {
|
|
437
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
438
|
+
res.end();
|
|
439
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
|
|
440
|
+
const row = { ...parsed, sig: 1, ip, tap: import_core3.TAP_NODE, rts: Date.now() };
|
|
441
|
+
for (const k of import_core3.REQUEST_ROW_KEYS) delete row[k];
|
|
442
|
+
this.enqueue(row);
|
|
443
|
+
};
|
|
444
|
+
const fromText = (text) => {
|
|
445
|
+
try {
|
|
446
|
+
return JSON.parse(text);
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
if (req.readableEnded) {
|
|
452
|
+
const b = parsedBody(req);
|
|
453
|
+
if ((typeof b === "string" ? b : b ? JSON.stringify(b) : "").length > FP_MAX) {
|
|
454
|
+
res.writeHead(413).end();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
accept(typeof b === "string" ? fromText(b) : b);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
269
460
|
const chunks = [];
|
|
270
461
|
let size = 0, dead = false;
|
|
271
462
|
req.on("data", (c) => {
|
|
@@ -280,16 +471,7 @@ var Camada = class {
|
|
|
280
471
|
});
|
|
281
472
|
req.on("end", () => (0, import_core3.guarded)(() => {
|
|
282
473
|
if (dead) return;
|
|
283
|
-
|
|
284
|
-
res.end();
|
|
285
|
-
let parsed;
|
|
286
|
-
try {
|
|
287
|
-
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
288
|
-
} catch {
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
292
|
-
this.queue.push({ ...parsed, sig: 1, ip, tap: import_core3.TAP_NODE });
|
|
474
|
+
accept(fromText(Buffer.concat(chunks).toString("utf8")));
|
|
293
475
|
}, void 0));
|
|
294
476
|
req.on("error", () => {
|
|
295
477
|
try {
|
|
@@ -301,15 +483,11 @@ var Camada = class {
|
|
|
301
483
|
challengePassed(req, ip) {
|
|
302
484
|
return !!this.kit?.tokenValid(ip, Date.now(), cookieValue(req.headers.cookie || "", import_core3.CHALLENGE_COOKIE));
|
|
303
485
|
}
|
|
304
|
-
/** 403 + the proof-of-work page (HTML navigations
|
|
486
|
+
/** 403 + the proof-of-work page (HTML navigations only), plus the
|
|
305
487
|
* `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
|
|
306
488
|
serveChallengeInner(req, res, ip, target) {
|
|
307
489
|
const to = (0, import_core3.safeReturnTo)(target);
|
|
308
|
-
|
|
309
|
-
writeChallengePage(res, (0, import_core3.challengePage)({ nonce: this.kit.nonce(ip, Date.now()), action: this.challengePath, to }));
|
|
310
|
-
} else {
|
|
311
|
-
writeChallengeJson(res);
|
|
312
|
-
}
|
|
490
|
+
writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
|
|
313
491
|
(0, import_core3.guarded)(() => {
|
|
314
492
|
const qi = target.indexOf("?");
|
|
315
493
|
const ev = this.buildEvent(
|
|
@@ -321,7 +499,7 @@ var Camada = class {
|
|
|
321
499
|
);
|
|
322
500
|
ev.st = 403;
|
|
323
501
|
ev.blk = "challenge";
|
|
324
|
-
this.
|
|
502
|
+
this.enqueue(ev);
|
|
325
503
|
}, void 0);
|
|
326
504
|
}
|
|
327
505
|
/** The request's existing session, so challenge rows join the session that produced them. */
|
|
@@ -331,12 +509,11 @@ var Camada = class {
|
|
|
331
509
|
/** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
|
|
332
510
|
* back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
|
|
333
511
|
verifyChallenge(req, res, ip) {
|
|
334
|
-
|
|
335
|
-
const form = (0, import_core3.parseFormBody)(body);
|
|
512
|
+
readForm(req, (form) => (0, import_core3.guarded)(() => {
|
|
336
513
|
const to = (0, import_core3.safeReturnTo)(form.to);
|
|
337
514
|
const now = Date.now();
|
|
338
515
|
if (!this.kit.verify(ip, now, form.nonce, form.solution)) {
|
|
339
|
-
writeChallengePage(res,
|
|
516
|
+
writeChallengePage(res, { nonce: this.kit.nonce(ip, now), action: this.challengeAction(req), to });
|
|
340
517
|
return;
|
|
341
518
|
}
|
|
342
519
|
res.writeHead(302, {
|
|
@@ -345,10 +522,10 @@ var Camada = class {
|
|
|
345
522
|
"cache-control": "no-store"
|
|
346
523
|
});
|
|
347
524
|
res.end();
|
|
348
|
-
const ev = this.buildEvent(req, this.
|
|
525
|
+
const ev = this.buildEvent(req, this.challengeAction(req), "", ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
|
|
349
526
|
ev.st = 200;
|
|
350
527
|
ev.ch = 1;
|
|
351
|
-
this.
|
|
528
|
+
this.enqueue(ev);
|
|
352
529
|
}, void 0));
|
|
353
530
|
}
|
|
354
531
|
/**
|
|
@@ -380,28 +557,38 @@ var Camada = class {
|
|
|
380
557
|
}
|
|
381
558
|
shipUpgrade(req, t0) {
|
|
382
559
|
if (this.disabled || !this.queue) return;
|
|
383
|
-
const
|
|
384
|
-
const qi = rawUrl.indexOf("?");
|
|
385
|
-
const path = qi === -1 ? rawUrl : rawUrl.slice(0, qi);
|
|
560
|
+
const { path, query } = pathAndQuery(req);
|
|
386
561
|
const cfg = this.snap?.config;
|
|
387
562
|
if ((cfg?.exclude || []).some((x) => path.startsWith(x)) || Math.random() >= (cfg?.sample ?? 1)) return;
|
|
388
|
-
const ip =
|
|
389
|
-
const ev = this.buildEvent(req, path,
|
|
563
|
+
const ip = this.clientIp(req);
|
|
564
|
+
const ev = this.buildEvent(req, path, query, ip, { rid: (0, import_node_crypto2.randomUUID)(), sid: this.sessionOf(req), newSession: false });
|
|
390
565
|
ev.ts = t0;
|
|
391
566
|
ev.st = 101;
|
|
392
567
|
ev.dur = Date.now() - t0;
|
|
393
|
-
this.
|
|
568
|
+
this.enqueue(ev);
|
|
394
569
|
}
|
|
395
570
|
/** Serve the challenge for this request on demand — for a route the app wants to gate itself
|
|
396
571
|
* (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
|
|
397
|
-
* so the caller renders its own page.
|
|
572
|
+
* so the caller renders its own page. A non-HTML request is never challenged: it gets the
|
|
573
|
+
* `x-camada-challenge: 1` header and a `chs: 1` event, and false, so the app answers. */
|
|
398
574
|
serveChallenge(req, res) {
|
|
399
575
|
return (0, import_core3.guarded)(() => {
|
|
400
576
|
if (this.disabled || !this.kit || !this.queue) return false;
|
|
401
|
-
const ip =
|
|
577
|
+
const ip = this.clientIp(req);
|
|
402
578
|
if (!ip || this.challengePassed(req, ip)) return false;
|
|
403
|
-
|
|
404
|
-
|
|
579
|
+
const r = req;
|
|
580
|
+
const { path, query } = pathAndQuery(r);
|
|
581
|
+
if (!isPage(r)) {
|
|
582
|
+
if (!r.camadaSignalled) signal(res);
|
|
583
|
+
r.camadaSignalled = true;
|
|
584
|
+
if (r.camadaOpen === void 0) {
|
|
585
|
+
const c = r.camada ?? this.begin(r, res, ip, Date.now());
|
|
586
|
+
this.hookCapture(r, res, path, query, ip, { rid: c.rid, sid: c.sid, newSession: c.ns, t0: c.t0 });
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
r.camadaChallenged = true;
|
|
591
|
+
this.serveChallengeInner(req, res, ip, path + query);
|
|
405
592
|
return true;
|
|
406
593
|
}, false);
|
|
407
594
|
}
|
|
@@ -409,7 +596,9 @@ var Camada = class {
|
|
|
409
596
|
scriptTag(req) {
|
|
410
597
|
if (this.disabled || !this.beaconEnabled()) return "";
|
|
411
598
|
const rid = req.camada?.rid;
|
|
412
|
-
|
|
599
|
+
const r = req;
|
|
600
|
+
const src = this.scriptPathSet ? this.scriptPath : (r.camadaPrefix ?? mountPrefix(r)) + this.scriptPath;
|
|
601
|
+
return `<script src="${src}${rid ? `?r=${rid}` : ""}" async></script>`;
|
|
413
602
|
}
|
|
414
603
|
/** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
|
|
415
604
|
* in-process; the raw value never reaches the queue. */
|
|
@@ -417,10 +606,12 @@ var Camada = class {
|
|
|
417
606
|
(0, import_core3.guarded)(() => {
|
|
418
607
|
if (this.disabled || !this.queue || !this.env) return;
|
|
419
608
|
const ctx = req.camada;
|
|
420
|
-
|
|
609
|
+
const r = req;
|
|
610
|
+
const row = (async () => {
|
|
421
611
|
const uid = data?.user ? await (0, import_core3.hashUserId)(data.user, this.env.ingestToken, (globalThis.crypto ?? import_node_crypto2.webcrypto).subtle) : null;
|
|
422
|
-
this.
|
|
612
|
+
this.enqueue({ tap: import_core3.TAP_NODE, et: event, uid, rid: ctx?.rid ?? null, sid: ctx?.sid ?? null, ip: ctx?.ip ?? null, ts: Date.now() }, r);
|
|
423
613
|
})().catch(import_core3.logRateLimited);
|
|
614
|
+
if (r.camadaOpen) (r.camadaTracks ??= []).push(row);
|
|
424
615
|
}, void 0);
|
|
425
616
|
}
|
|
426
617
|
/** Express/Connect middleware (also what NestJS on the default platform consumes). */
|
|
@@ -429,9 +620,11 @@ var Camada = class {
|
|
|
429
620
|
if (!this.handle(req, res)) next();
|
|
430
621
|
};
|
|
431
622
|
}
|
|
623
|
+
/** Stops polling and the event queue. The promise settles once rows still queued behind a drain
|
|
624
|
+
* in flight or a 429 hold are sent (or counted dropped); it never rejects. */
|
|
432
625
|
stop() {
|
|
433
626
|
this.snap?.stop();
|
|
434
|
-
this.queue?.stop();
|
|
627
|
+
return this.queue?.stop() ?? Promise.resolve();
|
|
435
628
|
}
|
|
436
629
|
};
|
|
437
630
|
function createCamada(opts = {}) {
|