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