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