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