@camada/node 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -3
- package/dist/{camada-Dh_9D6Sx.d.cts → camada-CFmJTruP.d.cts} +27 -2
- package/dist/{camada-Dh_9D6Sx.d.ts → camada-CFmJTruP.d.ts} +27 -2
- package/dist/{chunk-CR3UHMBJ.js → chunk-V3UWO5OT.js} +166 -51
- package/dist/chunk-V3UWO5OT.js.map +1 -0
- package/dist/fastify.cjs +164 -47
- 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 +164 -47
- 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 +166 -47
- 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 +164 -47
- 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-CR3UHMBJ.js.map +0 -1
package/README.md
CHANGED
|
@@ -23,8 +23,16 @@ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to productio
|
|
|
23
23
|
**Express / Connect**
|
|
24
24
|
```js
|
|
25
25
|
import camada from '@camada/node';
|
|
26
|
-
app.use(camada.express());
|
|
26
|
+
app.use(camada.express()); // before express.json() / urlencoded() and before helmet
|
|
27
27
|
```
|
|
28
|
+
Mount camada first. It still works behind body parsers (it uses the `req.body` they leave for
|
|
29
|
+
its challenge and beacon POSTs) and behind helmet (the challenge page replaces any inherited
|
|
30
|
+
`Content-Security-Policy` with its own), but first is cheapest: a blocked request never pays for
|
|
31
|
+
parsing. Under a prefix (`app.use('/api', camada.express())`) rules and events see the full path
|
|
32
|
+
(`/api/…`), and camada's own endpoints answer under the prefix (`/api/_cam/b.js`, `/api/_cam/fp`,
|
|
33
|
+
`/api/__camada/challenge`). The beacon tag (`scriptTag()`) and the challenge form point under the
|
|
34
|
+
prefix too, unless you set `scriptPath` or `challengePath` yourself: an explicit path is used
|
|
35
|
+
exactly as given. The beacon posts next to its script, so `fpPath` can stay as it is.
|
|
28
36
|
|
|
29
37
|
**Fastify**
|
|
30
38
|
```js
|
|
@@ -37,12 +45,18 @@ await app.register(camadaFastify);
|
|
|
37
45
|
import { CamadaModule } from '@camada/node/nest';
|
|
38
46
|
@Module({ imports: [CamadaModule.forRoot()] }) // Nest ≤10: CamadaModule.forRoot({ routes: '*' })
|
|
39
47
|
```
|
|
48
|
+
Nest registers its body parsers before module middleware; camada reads what they parsed, so the
|
|
49
|
+
default `bodyParser: true` is fine. Tested on Nest 11. Nest 10 (`routes: '*'`) is checked through
|
|
50
|
+
the Express 4 wildcard mount it registers, not against Nest 10 itself.
|
|
40
51
|
|
|
41
52
|
**Koa**
|
|
42
53
|
```js
|
|
43
54
|
import { camadaKoa } from '@camada/node/koa';
|
|
44
|
-
app.use(camadaKoa());
|
|
55
|
+
app.use(camadaKoa()); // before @koa/bodyparser
|
|
45
56
|
```
|
|
57
|
+
Register it first. Behind `@koa/bodyparser` or `koa-bodyparser` it reads the form and beacon from
|
|
58
|
+
`ctx.request.body`. Under `koa-mount` it works like an Express prefix mount: rules and events see the
|
|
59
|
+
full path, and its own endpoints answer under the prefix.
|
|
46
60
|
|
|
47
61
|
## What it does per request
|
|
48
62
|
|
|
@@ -50,6 +64,9 @@ app.use(camadaKoa());
|
|
|
50
64
|
poll and event batch carries `x-camada-sdk: @camada/node/<version>`.
|
|
51
65
|
2. Resolves the client IP per your tenant's trusted-proxy config — raw `X-Forwarded-For` is
|
|
52
66
|
never trusted without it (`CAMADA_TRUSTED_PROXY=hops:1|cidrs:…|vercel` overrides locally).
|
|
67
|
+
On a unix-socket listener (nginx or Passenger in front) Node has no peer address, so the walk
|
|
68
|
+
runs without one and trusts the local proxy that owns the socket. A TCP request whose peer is
|
|
69
|
+
already gone (the client reset the connection) gets no IP: no ip rules, no challenge.
|
|
53
70
|
3. Runs your ordered custom rules (see below), then the allow, block and challenge lists.
|
|
54
71
|
Blocked → `403` with `x-block-reason` before your app; the event still ships, with
|
|
55
72
|
`st: 403` and `blk: <reason>` (ip4|ip6|path|rule) so the analyst counts SDK blocks apart
|
|
@@ -145,10 +162,23 @@ handshake your library then rejects still ships `st: 101`.
|
|
|
145
162
|
|
|
146
163
|
- `CAMADA_DISABLED=1` — kill switch, checked per request.
|
|
147
164
|
- `CAMADA_SERVERLESS=1` — lazy snapshot refresh (no interval timer); cold invocations fail
|
|
148
|
-
open and catch up asynchronously.
|
|
165
|
+
open and catch up asynchronously. Each request's event (and every `track()` row) is POSTed as
|
|
166
|
+
the response finishes instead of waiting for the 15 s batch timer, which a frozen function
|
|
167
|
+
never runs. Pass the platform's `waitUntil` so the invocation stays alive for that POST:
|
|
168
|
+
```js
|
|
169
|
+
import { waitUntil } from '@vercel/functions';
|
|
170
|
+
const camada = createCamada({ waitUntil }); // also holds the lazy snapshot refresh open
|
|
171
|
+
```
|
|
172
|
+
Where the platform has no `waitUntil`, the POST starts before the response completes but can
|
|
173
|
+
still be frozen mid-flight; use a fetch adapter (`@camada/hono`, `@camada/next`) there.
|
|
174
|
+
- The challenge page sends its own `Content-Security-Policy` allowing only its inline script and
|
|
175
|
+
style by `sha256` hash, and drops any `Content-Security-Policy(-Report-Only)` set before it.
|
|
149
176
|
- Enforcement scope at this position: IP, path, user-agent and request-header conditions. ASN,
|
|
150
177
|
country and TLS-fingerprint conditions can't be evaluated in-app and fail open (a rule that
|
|
151
178
|
needs one never matches here; run `@camada/hono` on Cloudflare Workers for those).
|
|
179
|
+
- `await camada.stop()` in your own graceful shutdown stops polling and the event queue. It
|
|
180
|
+
returns a promise that settles once queued rows (a drain in flight, or rows held by an ingest
|
|
181
|
+
429) are sent, or counted as dropped if the send fails; it never rejects.
|
|
152
182
|
- Memory: ~5 MB per loaded snapshot.
|
|
153
183
|
|
|
154
184
|
## Develop
|
|
@@ -22,6 +22,9 @@ interface CamadaOptions {
|
|
|
22
22
|
challenge?: boolean;
|
|
23
23
|
challengePath?: string;
|
|
24
24
|
snapshotVersion?: SnapshotVersion;
|
|
25
|
+
/** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
|
|
26
|
+
* platform keeps the invocation alive until they finish (e.g. `waitUntil` from `@vercel/functions`). */
|
|
27
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
25
28
|
}
|
|
26
29
|
declare class Camada {
|
|
27
30
|
readonly env: ResolvedEnv | null;
|
|
@@ -32,19 +35,39 @@ declare class Camada {
|
|
|
32
35
|
private readonly fpPath;
|
|
33
36
|
private readonly challengeOn;
|
|
34
37
|
private readonly challengePath;
|
|
38
|
+
private readonly challengePathSet;
|
|
39
|
+
private readonly scriptPathSet;
|
|
35
40
|
private readonly kit;
|
|
36
41
|
private readonly envSource;
|
|
42
|
+
private readonly waitUntil?;
|
|
37
43
|
constructor(opts?: CamadaOptions);
|
|
38
44
|
get disabled(): boolean;
|
|
39
45
|
private trustedProxy;
|
|
46
|
+
/** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
|
|
47
|
+
* unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
|
|
48
|
+
* X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
|
|
49
|
+
* client reset the connection before camada ran, e.g. behind an async middleware) is
|
|
50
|
+
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
51
|
+
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
52
|
+
private clientIp;
|
|
53
|
+
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
54
|
+
* path was configured explicitly. */
|
|
55
|
+
private challengeAction;
|
|
40
56
|
private beaconEnabled;
|
|
57
|
+
/** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
|
|
58
|
+
* process once the response is out, so the 15 s interval would never run. Never throws. */
|
|
59
|
+
private enqueue;
|
|
60
|
+
/** Serverless: start the POST now and hand it to waitUntil. Never throws. */
|
|
61
|
+
private flushFor;
|
|
41
62
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
42
63
|
handle(req: IncomingMessage, res: ServerResponse): boolean;
|
|
43
64
|
private handleInner;
|
|
44
65
|
private buildEvent;
|
|
45
66
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
46
67
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
47
|
-
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
68
|
+
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
69
|
+
* A body parser mounted before camada (express.json(), always on Nest) has already drained the
|
|
70
|
+
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
48
71
|
private relayBeacon;
|
|
49
72
|
private challengePassed;
|
|
50
73
|
/** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
|
|
@@ -79,7 +102,9 @@ declare class Camada {
|
|
|
79
102
|
}): void;
|
|
80
103
|
/** Express/Connect middleware (also what NestJS on the default platform consumes). */
|
|
81
104
|
express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
|
|
82
|
-
|
|
105
|
+
/** Stops polling and the event queue. The promise settles once rows still queued behind a drain
|
|
106
|
+
* in flight or a 429 hold are sent (or counted dropped); it never rejects. */
|
|
107
|
+
stop(): Promise<void>;
|
|
83
108
|
}
|
|
84
109
|
declare function createCamada(opts?: CamadaOptions): Camada;
|
|
85
110
|
|
|
@@ -22,6 +22,9 @@ interface CamadaOptions {
|
|
|
22
22
|
challenge?: boolean;
|
|
23
23
|
challengePath?: string;
|
|
24
24
|
snapshotVersion?: SnapshotVersion;
|
|
25
|
+
/** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
|
|
26
|
+
* platform keeps the invocation alive until they finish (e.g. `waitUntil` from `@vercel/functions`). */
|
|
27
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
25
28
|
}
|
|
26
29
|
declare class Camada {
|
|
27
30
|
readonly env: ResolvedEnv | null;
|
|
@@ -32,19 +35,39 @@ declare class Camada {
|
|
|
32
35
|
private readonly fpPath;
|
|
33
36
|
private readonly challengeOn;
|
|
34
37
|
private readonly challengePath;
|
|
38
|
+
private readonly challengePathSet;
|
|
39
|
+
private readonly scriptPathSet;
|
|
35
40
|
private readonly kit;
|
|
36
41
|
private readonly envSource;
|
|
42
|
+
private readonly waitUntil?;
|
|
37
43
|
constructor(opts?: CamadaOptions);
|
|
38
44
|
get disabled(): boolean;
|
|
39
45
|
private trustedProxy;
|
|
46
|
+
/** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
|
|
47
|
+
* unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
|
|
48
|
+
* X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
|
|
49
|
+
* client reset the connection before camada ran, e.g. behind an async middleware) is
|
|
50
|
+
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
51
|
+
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
52
|
+
private clientIp;
|
|
53
|
+
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
54
|
+
* path was configured explicitly. */
|
|
55
|
+
private challengeAction;
|
|
40
56
|
private beaconEnabled;
|
|
57
|
+
/** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
|
|
58
|
+
* process once the response is out, so the 15 s interval would never run. Never throws. */
|
|
59
|
+
private enqueue;
|
|
60
|
+
/** Serverless: start the POST now and hand it to waitUntil. Never throws. */
|
|
61
|
+
private flushFor;
|
|
41
62
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
42
63
|
handle(req: IncomingMessage, res: ServerResponse): boolean;
|
|
43
64
|
private handleInner;
|
|
44
65
|
private buildEvent;
|
|
45
66
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
46
67
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
47
|
-
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
68
|
+
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
69
|
+
* A body parser mounted before camada (express.json(), always on Nest) has already drained the
|
|
70
|
+
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
48
71
|
private relayBeacon;
|
|
49
72
|
private challengePassed;
|
|
50
73
|
/** 403 + the proof-of-work page (HTML navigations) or 403 JSON (everything else), plus the
|
|
@@ -79,7 +102,9 @@ declare class Camada {
|
|
|
79
102
|
}): void;
|
|
80
103
|
/** Express/Connect middleware (also what NestJS on the default platform consumes). */
|
|
81
104
|
express(): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
|
|
82
|
-
|
|
105
|
+
/** Stops polling and the event queue. The promise settles once rows still queued behind a drain
|
|
106
|
+
* in flight or a 429 hold are sent (or counted dropped); it never rejects. */
|
|
107
|
+
stop(): Promise<void>;
|
|
83
108
|
}
|
|
84
109
|
declare function createCamada(opts?: CamadaOptions): Camada;
|
|
85
110
|
|
|
@@ -19,11 +19,9 @@ import {
|
|
|
19
19
|
hashUserId,
|
|
20
20
|
guarded,
|
|
21
21
|
logRateLimited,
|
|
22
|
-
challengePage,
|
|
23
22
|
challengeCookie,
|
|
24
23
|
safeReturnTo,
|
|
25
24
|
wantsHtml,
|
|
26
|
-
parseFormBody,
|
|
27
25
|
CHALLENGE_COOKIE,
|
|
28
26
|
TAP_NODE
|
|
29
27
|
} from "@camada/core";
|
|
@@ -50,7 +48,7 @@ function resolveEnv(env = process.env) {
|
|
|
50
48
|
|
|
51
49
|
// src/challenge.ts
|
|
52
50
|
import { createHash, createHmac } from "crypto";
|
|
53
|
-
import { createChallenge } from "@camada/core";
|
|
51
|
+
import { createChallenge, challengePage, challengePageCsp, parseFormBody } from "@camada/core";
|
|
54
52
|
var CHALLENGE_PATH = "/__camada/challenge";
|
|
55
53
|
var BODY_MAX = 4 * 1024;
|
|
56
54
|
function nodeChallengeKit(secret) {
|
|
@@ -76,10 +74,41 @@ function readBody(req, done) {
|
|
|
76
74
|
req.on("end", () => done(dead ? "" : Buffer.concat(chunks).toString("utf8")));
|
|
77
75
|
req.on("error", () => done(""));
|
|
78
76
|
}
|
|
77
|
+
var FORM_FIELDS = ["nonce", "solution", "to"];
|
|
78
|
+
function parsedBody(req) {
|
|
79
|
+
const b = req.body;
|
|
80
|
+
if (typeof b === "string") return b;
|
|
81
|
+
if (Buffer.isBuffer(b)) return b.toString("utf8");
|
|
82
|
+
return b && typeof b === "object" ? b : null;
|
|
83
|
+
}
|
|
84
|
+
function readForm(req, done) {
|
|
85
|
+
if (!req.readableEnded) {
|
|
86
|
+
readBody(req, (body) => done(parseFormBody(body)));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const b = parsedBody(req);
|
|
90
|
+
if (typeof b === "string") {
|
|
91
|
+
done(b.length > BODY_MAX ? {} : parseFormBody(b));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const form = /* @__PURE__ */ Object.create(null);
|
|
95
|
+
for (const k of FORM_FIELDS) {
|
|
96
|
+
const v = b?.[k];
|
|
97
|
+
if (typeof v === "string") form[k] = v;
|
|
98
|
+
}
|
|
99
|
+
done(form);
|
|
100
|
+
}
|
|
79
101
|
var isHttps = (req) => !!req.socket?.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
80
|
-
function writeChallengePage(res,
|
|
81
|
-
res.
|
|
82
|
-
res.
|
|
102
|
+
function writeChallengePage(res, o) {
|
|
103
|
+
res.removeHeader("content-security-policy");
|
|
104
|
+
res.removeHeader("content-security-policy-report-only");
|
|
105
|
+
res.writeHead(403, {
|
|
106
|
+
"content-type": "text/html; charset=utf-8",
|
|
107
|
+
"cache-control": "no-store",
|
|
108
|
+
"x-camada-challenge": "1",
|
|
109
|
+
"content-security-policy": challengePageCsp(o.bits)
|
|
110
|
+
});
|
|
111
|
+
res.end(challengePage(o));
|
|
83
112
|
}
|
|
84
113
|
function writeChallengeJson(res) {
|
|
85
114
|
res.writeHead(403, { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" });
|
|
@@ -87,7 +116,7 @@ function writeChallengeJson(res) {
|
|
|
87
116
|
}
|
|
88
117
|
|
|
89
118
|
// package.json
|
|
90
|
-
var version = "0.3.
|
|
119
|
+
var version = "0.3.2";
|
|
91
120
|
var name = "@camada/node";
|
|
92
121
|
|
|
93
122
|
// src/version.ts
|
|
@@ -103,6 +132,28 @@ var headerReader = (req) => (name2) => {
|
|
|
103
132
|
const v = req.headers[name2];
|
|
104
133
|
return v == null ? null : Array.isArray(v) ? v.join(", ") : v;
|
|
105
134
|
};
|
|
135
|
+
var splitUrl = (raw) => {
|
|
136
|
+
const qi = raw.indexOf("?");
|
|
137
|
+
return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
|
|
138
|
+
};
|
|
139
|
+
function pathAndQuery(req) {
|
|
140
|
+
return splitUrl(req.originalUrl || req.url || "/");
|
|
141
|
+
}
|
|
142
|
+
var atEndpoint = (req, path, endpoint) => path === endpoint || splitUrl(req.url || "/").path === endpoint;
|
|
143
|
+
function mountPrefix(req) {
|
|
144
|
+
if (!req.originalUrl) return "";
|
|
145
|
+
const full = splitUrl(req.originalUrl).path, rel = splitUrl(req.url || "/").path;
|
|
146
|
+
return rel !== "/" && full !== rel && full.endsWith(rel) ? full.slice(0, full.length - rel.length) : "";
|
|
147
|
+
}
|
|
148
|
+
function onUnixListener(socket) {
|
|
149
|
+
const srv = socket?.server;
|
|
150
|
+
if (!srv) return false;
|
|
151
|
+
try {
|
|
152
|
+
if (typeof srv.address?.() === "string") return true;
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
return typeof srv._pipeName === "string";
|
|
156
|
+
}
|
|
106
157
|
var cookieValue = (cookie, name2) => {
|
|
107
158
|
const src = "; " + cookie;
|
|
108
159
|
const i = src.indexOf("; " + name2 + "=");
|
|
@@ -131,15 +182,22 @@ var Camada = class {
|
|
|
131
182
|
fpPath;
|
|
132
183
|
challengeOn;
|
|
133
184
|
challengePath;
|
|
185
|
+
challengePathSet;
|
|
186
|
+
// configured explicitly: used as given, never prefixed
|
|
187
|
+
scriptPathSet;
|
|
134
188
|
kit = null;
|
|
135
189
|
envSource;
|
|
190
|
+
waitUntil;
|
|
136
191
|
constructor(opts = {}) {
|
|
137
192
|
this.envSource = opts.env ?? process.env;
|
|
138
193
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
194
|
+
this.waitUntil = opts.waitUntil;
|
|
139
195
|
this.scriptPath = opts.scriptPath ?? SCRIPT_PATH;
|
|
140
196
|
this.fpPath = opts.fpPath ?? FP_PATH;
|
|
141
197
|
this.challengeOn = opts.challenge !== false && this.envSource.CAMADA_CHALLENGE !== "0";
|
|
142
198
|
this.challengePath = opts.challengePath ?? CHALLENGE_PATH;
|
|
199
|
+
this.challengePathSet = opts.challengePath != null;
|
|
200
|
+
this.scriptPathSet = opts.scriptPath != null;
|
|
143
201
|
this.env = resolveEnv(this.envSource);
|
|
144
202
|
if (!this.env) return;
|
|
145
203
|
if (this.envSource.CAMADA_DISABLED === "1") return;
|
|
@@ -163,9 +221,43 @@ var Camada = class {
|
|
|
163
221
|
trustedProxy() {
|
|
164
222
|
return this.env?.trustedProxy ?? this.snap?.config?.trusted_proxy ?? null;
|
|
165
223
|
}
|
|
224
|
+
/** The client IP per the trusted-proxy config (team contract C6 v3). No socket at all, or a
|
|
225
|
+
* unix-socket listener (nginx/Passenger in front, where Node never has a peer): core's no-peer
|
|
226
|
+
* X-Forwarded-For walk, which trusts the local proxy. A TCP socket with no readable peer (the
|
|
227
|
+
* client reset the connection before camada ran, e.g. behind an async middleware) is
|
|
228
|
+
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
229
|
+
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
230
|
+
clientIp(req) {
|
|
231
|
+
const peer = req.socket?.remoteAddress;
|
|
232
|
+
if (!peer && req.socket && !onUnixListener(req.socket)) return null;
|
|
233
|
+
return resolveClientIp(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
|
|
234
|
+
}
|
|
235
|
+
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
236
|
+
* path was configured explicitly. */
|
|
237
|
+
challengeAction(req) {
|
|
238
|
+
return this.challengePathSet ? this.challengePath : (req.camadaPrefix ?? mountPrefix(req)) + this.challengePath;
|
|
239
|
+
}
|
|
166
240
|
beaconEnabled() {
|
|
167
241
|
return this.snap?.config?.beacon !== false;
|
|
168
242
|
}
|
|
243
|
+
/** Queues a row. With CAMADA_SERVERLESS=1 it also starts the POST now: the platform freezes the
|
|
244
|
+
* process once the response is out, so the 15 s interval would never run. Never throws. */
|
|
245
|
+
enqueue(row, req) {
|
|
246
|
+
const q = this.queue;
|
|
247
|
+
q.push(row);
|
|
248
|
+
if (!this.env?.serverless) return;
|
|
249
|
+
if (req?.camadaOpen) return;
|
|
250
|
+
this.flushFor(req);
|
|
251
|
+
}
|
|
252
|
+
/** Serverless: start the POST now and hand it to waitUntil. Never throws. */
|
|
253
|
+
flushFor(req) {
|
|
254
|
+
const q = this.queue;
|
|
255
|
+
guarded(() => {
|
|
256
|
+
const tracks = req?.camadaTracks ?? [];
|
|
257
|
+
const p = Promise.allSettled(tracks).then(() => q.flush()).then(() => q.size > 0 ? q.flush() : void 0);
|
|
258
|
+
this.waitUntil?.(p);
|
|
259
|
+
}, void 0);
|
|
260
|
+
}
|
|
169
261
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
170
262
|
handle(req, res) {
|
|
171
263
|
return guarded(() => this.handleInner(req, res), false);
|
|
@@ -173,15 +265,13 @@ var Camada = class {
|
|
|
173
265
|
handleInner(req, res) {
|
|
174
266
|
if (this.disabled || !this.snap || !this.queue || !this.env) return false;
|
|
175
267
|
const t0 = Date.now();
|
|
176
|
-
this.snap.ensureFresh();
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
const query = qi === -1 ? "" : rawUrl.slice(qi);
|
|
181
|
-
const ip = resolveClientIp(req.socket?.remoteAddress, req.headers["x-forwarded-for"], this.trustedProxy());
|
|
268
|
+
this.snap.ensureFresh(this.env.serverless ? this.waitUntil : void 0);
|
|
269
|
+
const { path, query } = pathAndQuery(req);
|
|
270
|
+
const ip = this.clientIp(req);
|
|
271
|
+
req.camadaPrefix = mountPrefix(req);
|
|
182
272
|
const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
|
|
183
273
|
if (v.block) {
|
|
184
|
-
const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain" };
|
|
274
|
+
const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
|
|
185
275
|
if (v.rule) headers["x-block-rule"] = v.rule;
|
|
186
276
|
res.writeHead(403, headers);
|
|
187
277
|
res.end("Forbidden");
|
|
@@ -189,11 +279,11 @@ var Camada = class {
|
|
|
189
279
|
ev.st = 403;
|
|
190
280
|
ev.blk = v.reason;
|
|
191
281
|
if (v.rule) ev.rl = v.rule;
|
|
192
|
-
this.
|
|
282
|
+
this.enqueue(ev);
|
|
193
283
|
return true;
|
|
194
284
|
}
|
|
195
285
|
if (this.challengeOn && this.kit && ip) {
|
|
196
|
-
if (req.method === "POST" && path
|
|
286
|
+
if (req.method === "POST" && atEndpoint(req, path, this.challengePath)) {
|
|
197
287
|
this.verifyChallenge(req, res, ip);
|
|
198
288
|
return true;
|
|
199
289
|
}
|
|
@@ -203,12 +293,12 @@ var Camada = class {
|
|
|
203
293
|
}
|
|
204
294
|
}
|
|
205
295
|
if (this.beaconEnabled()) {
|
|
206
|
-
if (req.method === "GET" && path
|
|
296
|
+
if (req.method === "GET" && atEndpoint(req, path, this.scriptPath)) {
|
|
207
297
|
res.writeHead(200, { "content-type": "application/javascript", "cache-control": "public, max-age=3600" });
|
|
208
298
|
res.end(iife);
|
|
209
299
|
return true;
|
|
210
300
|
}
|
|
211
|
-
if (req.method === "POST" && path
|
|
301
|
+
if (req.method === "POST" && atEndpoint(req, path, this.fpPath)) {
|
|
212
302
|
this.relayBeacon(req, res, ip);
|
|
213
303
|
return true;
|
|
214
304
|
}
|
|
@@ -227,16 +317,22 @@ var Camada = class {
|
|
|
227
317
|
const excluded = (cfg?.exclude || []).some((x) => path.startsWith(x));
|
|
228
318
|
if (!excluded && Math.random() < (cfg?.sample ?? 1)) {
|
|
229
319
|
let shipped = false;
|
|
320
|
+
req.camadaOpen = true;
|
|
230
321
|
const ship = () => guarded(() => {
|
|
231
|
-
|
|
322
|
+
req.camadaOpen = false;
|
|
323
|
+
if (shipped) return;
|
|
232
324
|
shipped = true;
|
|
325
|
+
if (req.camadaChallenged) {
|
|
326
|
+
if (this.env?.serverless) this.flushFor(req);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
233
329
|
const ev = this.buildEvent(req, path, query, ip, { rid, sid, newSession });
|
|
234
330
|
ev.ts = t0;
|
|
235
331
|
ev.st = res.statusCode;
|
|
236
332
|
ev.dur = Date.now() - t0;
|
|
237
333
|
if (req.route?.path) ev.rt = String(req.route.path);
|
|
238
334
|
if (v.warn && v.rule) ev.wrn = v.rule;
|
|
239
|
-
this.
|
|
335
|
+
this.enqueue(ev, req);
|
|
240
336
|
}, void 0);
|
|
241
337
|
res.once("finish", ship);
|
|
242
338
|
res.once("close", ship);
|
|
@@ -254,8 +350,32 @@ var Camada = class {
|
|
|
254
350
|
}
|
|
255
351
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
256
352
|
* 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.
|
|
353
|
+
* sees one request per flush instead of one per page view. Junk bodies are dropped, never shipped.
|
|
354
|
+
* A body parser mounted before camada (express.json(), always on Nest) has already drained the
|
|
355
|
+
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
258
356
|
relayBeacon(req, res, ip) {
|
|
357
|
+
const accept = (parsed) => {
|
|
358
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
359
|
+
res.end();
|
|
360
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
|
|
361
|
+
this.enqueue({ ...parsed, sig: 1, ip, tap: TAP_NODE });
|
|
362
|
+
};
|
|
363
|
+
const fromText = (text) => {
|
|
364
|
+
try {
|
|
365
|
+
return JSON.parse(text);
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
if (req.readableEnded) {
|
|
371
|
+
const b = parsedBody(req);
|
|
372
|
+
if ((typeof b === "string" ? b : b ? JSON.stringify(b) : "").length > FP_MAX) {
|
|
373
|
+
res.writeHead(413).end();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
accept(typeof b === "string" ? fromText(b) : b);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
259
379
|
const chunks = [];
|
|
260
380
|
let size = 0, dead = false;
|
|
261
381
|
req.on("data", (c) => {
|
|
@@ -270,16 +390,7 @@ var Camada = class {
|
|
|
270
390
|
});
|
|
271
391
|
req.on("end", () => guarded(() => {
|
|
272
392
|
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 });
|
|
393
|
+
accept(fromText(Buffer.concat(chunks).toString("utf8")));
|
|
283
394
|
}, void 0));
|
|
284
395
|
req.on("error", () => {
|
|
285
396
|
try {
|
|
@@ -296,7 +407,7 @@ var Camada = class {
|
|
|
296
407
|
serveChallengeInner(req, res, ip, target) {
|
|
297
408
|
const to = safeReturnTo(target);
|
|
298
409
|
if (wantsHtml(req.headers.accept ?? null, req.headers["sec-fetch-dest"] ?? null)) {
|
|
299
|
-
writeChallengePage(res,
|
|
410
|
+
writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
|
|
300
411
|
} else {
|
|
301
412
|
writeChallengeJson(res);
|
|
302
413
|
}
|
|
@@ -311,7 +422,7 @@ var Camada = class {
|
|
|
311
422
|
);
|
|
312
423
|
ev.st = 403;
|
|
313
424
|
ev.blk = "challenge";
|
|
314
|
-
this.
|
|
425
|
+
this.enqueue(ev);
|
|
315
426
|
}, void 0);
|
|
316
427
|
}
|
|
317
428
|
/** The request's existing session, so challenge rows join the session that produced them. */
|
|
@@ -321,12 +432,11 @@ var Camada = class {
|
|
|
321
432
|
/** POST from the challenge page: validate the nonce and the proof of work, set `_cch`, 302
|
|
322
433
|
* back to the (sanitised, same-site) original URL, and ship `{ st: 200, ch: 1 }`. */
|
|
323
434
|
verifyChallenge(req, res, ip) {
|
|
324
|
-
|
|
325
|
-
const form = parseFormBody(body);
|
|
435
|
+
readForm(req, (form) => guarded(() => {
|
|
326
436
|
const to = safeReturnTo(form.to);
|
|
327
437
|
const now = Date.now();
|
|
328
438
|
if (!this.kit.verify(ip, now, form.nonce, form.solution)) {
|
|
329
|
-
writeChallengePage(res,
|
|
439
|
+
writeChallengePage(res, { nonce: this.kit.nonce(ip, now), action: this.challengeAction(req), to });
|
|
330
440
|
return;
|
|
331
441
|
}
|
|
332
442
|
res.writeHead(302, {
|
|
@@ -335,10 +445,10 @@ var Camada = class {
|
|
|
335
445
|
"cache-control": "no-store"
|
|
336
446
|
});
|
|
337
447
|
res.end();
|
|
338
|
-
const ev = this.buildEvent(req, this.
|
|
448
|
+
const ev = this.buildEvent(req, this.challengeAction(req), "", ip, { rid: randomUUID(), sid: this.sessionOf(req), newSession: false });
|
|
339
449
|
ev.st = 200;
|
|
340
450
|
ev.ch = 1;
|
|
341
|
-
this.
|
|
451
|
+
this.enqueue(ev);
|
|
342
452
|
}, void 0));
|
|
343
453
|
}
|
|
344
454
|
/**
|
|
@@ -370,17 +480,15 @@ var Camada = class {
|
|
|
370
480
|
}
|
|
371
481
|
shipUpgrade(req, t0) {
|
|
372
482
|
if (this.disabled || !this.queue) return;
|
|
373
|
-
const
|
|
374
|
-
const qi = rawUrl.indexOf("?");
|
|
375
|
-
const path = qi === -1 ? rawUrl : rawUrl.slice(0, qi);
|
|
483
|
+
const { path, query } = pathAndQuery(req);
|
|
376
484
|
const cfg = this.snap?.config;
|
|
377
485
|
if ((cfg?.exclude || []).some((x) => path.startsWith(x)) || Math.random() >= (cfg?.sample ?? 1)) return;
|
|
378
|
-
const ip =
|
|
379
|
-
const ev = this.buildEvent(req, path,
|
|
486
|
+
const ip = this.clientIp(req);
|
|
487
|
+
const ev = this.buildEvent(req, path, query, ip, { rid: randomUUID(), sid: this.sessionOf(req), newSession: false });
|
|
380
488
|
ev.ts = t0;
|
|
381
489
|
ev.st = 101;
|
|
382
490
|
ev.dur = Date.now() - t0;
|
|
383
|
-
this.
|
|
491
|
+
this.enqueue(ev);
|
|
384
492
|
}
|
|
385
493
|
/** Serve the challenge for this request on demand — for a route the app wants to gate itself
|
|
386
494
|
* (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
|
|
@@ -388,10 +496,11 @@ var Camada = class {
|
|
|
388
496
|
serveChallenge(req, res) {
|
|
389
497
|
return guarded(() => {
|
|
390
498
|
if (this.disabled || !this.kit || !this.queue) return false;
|
|
391
|
-
const ip =
|
|
499
|
+
const ip = this.clientIp(req);
|
|
392
500
|
if (!ip || this.challengePassed(req, ip)) return false;
|
|
393
501
|
req.camadaChallenged = true;
|
|
394
|
-
|
|
502
|
+
const { path, query } = pathAndQuery(req);
|
|
503
|
+
this.serveChallengeInner(req, res, ip, path + query);
|
|
395
504
|
return true;
|
|
396
505
|
}, false);
|
|
397
506
|
}
|
|
@@ -399,7 +508,9 @@ var Camada = class {
|
|
|
399
508
|
scriptTag(req) {
|
|
400
509
|
if (this.disabled || !this.beaconEnabled()) return "";
|
|
401
510
|
const rid = req.camada?.rid;
|
|
402
|
-
|
|
511
|
+
const r = req;
|
|
512
|
+
const src = this.scriptPathSet ? this.scriptPath : (r.camadaPrefix ?? mountPrefix(r)) + this.scriptPath;
|
|
513
|
+
return `<script src="${src}${rid ? `?r=${rid}` : ""}" async></script>`;
|
|
403
514
|
}
|
|
404
515
|
/** App-context outcome events (login failed, signup, …). The identifier is HMAC-hashed
|
|
405
516
|
* in-process; the raw value never reaches the queue. */
|
|
@@ -407,10 +518,12 @@ var Camada = class {
|
|
|
407
518
|
guarded(() => {
|
|
408
519
|
if (this.disabled || !this.queue || !this.env) return;
|
|
409
520
|
const ctx = req.camada;
|
|
410
|
-
|
|
521
|
+
const r = req;
|
|
522
|
+
const row = (async () => {
|
|
411
523
|
const uid = data?.user ? await hashUserId(data.user, this.env.ingestToken, (globalThis.crypto ?? webcrypto).subtle) : null;
|
|
412
|
-
this.
|
|
524
|
+
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
525
|
})().catch(logRateLimited);
|
|
526
|
+
if (r.camadaOpen) (r.camadaTracks ??= []).push(row);
|
|
414
527
|
}, void 0);
|
|
415
528
|
}
|
|
416
529
|
/** Express/Connect middleware (also what NestJS on the default platform consumes). */
|
|
@@ -419,9 +532,11 @@ var Camada = class {
|
|
|
419
532
|
if (!this.handle(req, res)) next();
|
|
420
533
|
};
|
|
421
534
|
}
|
|
535
|
+
/** Stops polling and the event queue. The promise settles once rows still queued behind a drain
|
|
536
|
+
* in flight or a 429 hold are sent (or counted dropped); it never rejects. */
|
|
422
537
|
stop() {
|
|
423
538
|
this.snap?.stop();
|
|
424
|
-
this.queue?.stop();
|
|
539
|
+
return this.queue?.stop() ?? Promise.resolve();
|
|
425
540
|
}
|
|
426
541
|
};
|
|
427
542
|
function createCamada(opts = {}) {
|
|
@@ -462,4 +577,4 @@ export {
|
|
|
462
577
|
configure,
|
|
463
578
|
index_default
|
|
464
579
|
};
|
|
465
|
-
//# sourceMappingURL=chunk-
|
|
580
|
+
//# sourceMappingURL=chunk-V3UWO5OT.js.map
|