@camada/node 0.3.2 → 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 +39 -3
- package/dist/{camada-CFmJTruP.d.cts → camada-DuHQ4sLn.d.cts} +19 -2
- package/dist/{camada-CFmJTruP.d.ts → camada-DuHQ4sLn.d.ts} +19 -2
- package/dist/{chunk-V3UWO5OT.js → chunk-44FSZJ5F.js} +127 -49
- package/dist/chunk-44FSZJ5F.js.map +1 -0
- package/dist/fastify.cjs +123 -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 +123 -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 +123 -47
- package/dist/koa.cjs.map +1 -1
- package/dist/koa.d.cts +1 -1
- package/dist/koa.d.ts +1 -1
- package/dist/koa.js +1 -1
- package/dist/nest.cjs +123 -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 +3 -3
- package/dist/chunk-V3UWO5OT.js.map +0 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ carries one of four actions:
|
|
|
89
89
|
|---|---|---|
|
|
90
90
|
| `skip` | passes the request | nothing |
|
|
91
91
|
| `block` | `403` before your app | `blk: "rule"`, `rl: "<rule id>"` |
|
|
92
|
-
| `challenge` | serves the proof-of-work page (`challenge: false` opts out) | `blk: "challenge"` |
|
|
92
|
+
| `challenge` | serves the proof-of-work page to page loads (`challenge: false` opts out). A non-HTML request (fetch/XHR/API) is not challenged: it reaches your app with `x-camada-challenge: 1` on the response | page: `blk: "challenge"`; non-HTML: its normal event plus `chs: 1` (never sampled out or excluded) |
|
|
93
93
|
| `warn` | passes the request and marks it for the analyst | `wrn: "<rule id>"` |
|
|
94
94
|
|
|
95
95
|
A skip rule also carries a *record matches* flag, which only the analyst reads: a recorded skip
|
|
@@ -140,6 +140,40 @@ fire on your app's own truth instead of path heuristics:
|
|
|
140
140
|
| `payment_failed` / `payment_succeeded` | a payment authorisation settled |
|
|
141
141
|
| `coupon_failed` | a promo/voucher code was rejected |
|
|
142
142
|
|
|
143
|
+
## TLS fingerprint behind Cloudflare
|
|
144
|
+
|
|
145
|
+
Node has no TLS handshake to read, but Cloudflare does. Set `CAMADA_TLSX_HEADER=x-camada-tlsx`
|
|
146
|
+
(or `createCamada({ tlsxHeader: 'x-camada-tlsx' })`) and add a Transform Rule that sets it:
|
|
147
|
+
*Rules > Transform Rules > Modify Request Header*, "Set dynamic", header `x-camada-tlsx`, value
|
|
148
|
+
`cf.tls_client_extensions_sha1`, on every request. The rule is required and must **set**
|
|
149
|
+
(overwrite) the header: without it a client-sent `x-camada-tlsx` passes through Cloudflare
|
|
150
|
+
untouched. The value feeds the same matcher as `@camada/hono`'s `request.cf.tlsClientExtensionsSha1`:
|
|
151
|
+
the snapshot's `tls` entries block with `x-block-reason: tls`, and `tlsx is` / `is_in` rule
|
|
152
|
+
conditions apply.
|
|
153
|
+
|
|
154
|
+
This needs Cloudflare to connect to the app directly. The header is believed only in `cidrs` mode,
|
|
155
|
+
when the socket peer is a non-loopback address inside Cloudflare's ranges, because anyone else can
|
|
156
|
+
write it:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
CAMADA_TRUSTED_PROXY=cidrs:173.245.48.0/20,103.21.244.0/22,… # Cloudflare's published ranges
|
|
160
|
+
CAMADA_TLSX_HEADER=x-camada-tlsx
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Every range in `CAMADA_TRUSTED_PROXY=cidrs:…` is trusted to write the header, the same trust it gets
|
|
164
|
+
for `X-Forwarded-For`, so list only Cloudflare's ranges there. A non-Cloudflare proxy range (a docker
|
|
165
|
+
bridge, an internal load balancer) would let anyone who reaches that proxy set the fingerprint.
|
|
166
|
+
|
|
167
|
+
Behind a local reverse proxy (nginx or Caddy on loopback) or a cloudflared tunnel the peer is
|
|
168
|
+
loopback, so the header is not read and `tlsx` conditions fail open; `X-Forwarded-For` is never
|
|
169
|
+
consulted. The same holds in `none`, `hops` or `vercel` mode, from any other peer, with no readable
|
|
170
|
+
peer (unix listeners included), or with a malformed value (over 128 characters, outside
|
|
171
|
+
`[A-Za-z0-9+/=_-]`, or a repeated header). The Transform Rule must overwrite the header on every
|
|
172
|
+
request: on a request it skips, a client-sent value passes through. The same trusted value is sent
|
|
173
|
+
to camada as `tlsx` on request events (pass, block, challenge and upgrade rows); node never adds it
|
|
174
|
+
to `track()` rows or the beacon relay, and a value that failed the checks above is never sent. The
|
|
175
|
+
analyst uses it to group traffic by TLS fingerprint and to recommend or block rare fingerprints.
|
|
176
|
+
|
|
143
177
|
## WebSockets
|
|
144
178
|
|
|
145
179
|
Node hands an upgrade request (a WebSocket handshake) to the server's `'upgrade'` listeners,
|
|
@@ -174,8 +208,10 @@ handshake your library then rejects still ships `st: 101`.
|
|
|
174
208
|
- The challenge page sends its own `Content-Security-Policy` allowing only its inline script and
|
|
175
209
|
style by `sha256` hash, and drops any `Content-Security-Policy(-Report-Only)` set before it.
|
|
176
210
|
- Enforcement scope at this position: IP, path, user-agent and request-header conditions. ASN,
|
|
177
|
-
country
|
|
178
|
-
|
|
211
|
+
country conditions can't be evaluated in-app and fail open (a rule that needs one never
|
|
212
|
+
matches here; run `@camada/hono` on Cloudflare Workers for those). TLS-fingerprint conditions
|
|
213
|
+
match only with the opt-in `CAMADA_TLSX_HEADER` (see "TLS fingerprint behind Cloudflare");
|
|
214
|
+
without it they fail open too.
|
|
179
215
|
- `await camada.stop()` in your own graceful shutdown stops polling and the event queue. It
|
|
180
216
|
returns a promise that settles once queued rows (a drain in flight, or rows held by an ingest
|
|
181
217
|
429) are sent, or counted as dropped if the send fails; it never rejects.
|
|
@@ -9,6 +9,7 @@ interface ResolvedEnv {
|
|
|
9
9
|
snapshotUrl: string;
|
|
10
10
|
serverless: boolean;
|
|
11
11
|
trustedProxy: TrustedProxyConfig | null;
|
|
12
|
+
tlsxHeader: string | null;
|
|
12
13
|
}
|
|
13
14
|
/** Returns null (SDK stays disabled, one log line) rather than throwing on bad config. */
|
|
14
15
|
declare function resolveEnv(env?: Record<string, string | undefined>): ResolvedEnv | null;
|
|
@@ -20,6 +21,7 @@ interface CamadaOptions {
|
|
|
20
21
|
scriptPath?: string;
|
|
21
22
|
fpPath?: string;
|
|
22
23
|
challenge?: boolean;
|
|
24
|
+
tlsxHeader?: string;
|
|
23
25
|
challengePath?: string;
|
|
24
26
|
snapshotVersion?: SnapshotVersion;
|
|
25
27
|
/** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
|
|
@@ -39,6 +41,7 @@ declare class Camada {
|
|
|
39
41
|
private readonly scriptPathSet;
|
|
40
42
|
private readonly kit;
|
|
41
43
|
private readonly envSource;
|
|
44
|
+
private readonly tlsxHeader;
|
|
42
45
|
private readonly waitUntil?;
|
|
43
46
|
constructor(opts?: CamadaOptions);
|
|
44
47
|
get disabled(): boolean;
|
|
@@ -50,6 +53,14 @@ declare class Camada {
|
|
|
50
53
|
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
51
54
|
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
52
55
|
private clientIp;
|
|
56
|
+
/** The TLS fingerprint Cloudflare (a Transform Rule) put in the opt-in header, or null. Trusted only
|
|
57
|
+
* in `cidrs` mode and only when the socket peer itself is a NON-loopback address core's cidrs walk
|
|
58
|
+
* trusts, i.e. Cloudflare connects to the app directly. A loopback peer (a local reverse proxy or
|
|
59
|
+
* cloudflared) is never trusted and X-Forwarded-For is never consulted, so the header is not read
|
|
60
|
+
* and tlsx conditions fail open. Trust is core's isTrustedPeer, the same first-hop test the cidrs
|
|
61
|
+
* walk uses. No readable peer (unix listener included) -> null, since core's no-peer walk would
|
|
62
|
+
* trust everyone. */
|
|
63
|
+
private tlsxOf;
|
|
53
64
|
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
54
65
|
* path was configured explicitly. */
|
|
55
66
|
private challengeAction;
|
|
@@ -62,6 +73,11 @@ declare class Camada {
|
|
|
62
73
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
63
74
|
handle(req: IncomingMessage, res: ServerResponse): boolean;
|
|
64
75
|
private handleInner;
|
|
76
|
+
/** Mints the request's rid and session (setting the cookie on a first visit), records them on
|
|
77
|
+
* `req.camada` and sets `x-rid`. handle() and serveChallenge() (when handle() did not run) share it. */
|
|
78
|
+
private begin;
|
|
79
|
+
/** Installs the response-finish hook that ships the request's normal capture event. */
|
|
80
|
+
private hookCapture;
|
|
65
81
|
private buildEvent;
|
|
66
82
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
67
83
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
@@ -70,7 +86,7 @@ declare class Camada {
|
|
|
70
86
|
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
71
87
|
private relayBeacon;
|
|
72
88
|
private challengePassed;
|
|
73
|
-
/** 403 + the proof-of-work page (HTML navigations
|
|
89
|
+
/** 403 + the proof-of-work page (HTML navigations only), plus the
|
|
74
90
|
* `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
|
|
75
91
|
private serveChallengeInner;
|
|
76
92
|
/** The request's existing session, so challenge rows join the session that produced them. */
|
|
@@ -91,7 +107,8 @@ declare class Camada {
|
|
|
91
107
|
private shipUpgrade;
|
|
92
108
|
/** Serve the challenge for this request on demand — for a route the app wants to gate itself
|
|
93
109
|
* (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
|
|
94
|
-
* so the caller renders its own page.
|
|
110
|
+
* so the caller renders its own page. A non-HTML request is never challenged: it gets the
|
|
111
|
+
* `x-camada-challenge: 1` header and a `chs: 1` event, and false, so the app answers. */
|
|
95
112
|
serveChallenge(req: IncomingMessage, res: ServerResponse): boolean;
|
|
96
113
|
/** For HTML templates: the first-party beacon tag with the request's rid. */
|
|
97
114
|
scriptTag(req: IncomingMessage): string;
|
|
@@ -9,6 +9,7 @@ interface ResolvedEnv {
|
|
|
9
9
|
snapshotUrl: string;
|
|
10
10
|
serverless: boolean;
|
|
11
11
|
trustedProxy: TrustedProxyConfig | null;
|
|
12
|
+
tlsxHeader: string | null;
|
|
12
13
|
}
|
|
13
14
|
/** Returns null (SDK stays disabled, one log line) rather than throwing on bad config. */
|
|
14
15
|
declare function resolveEnv(env?: Record<string, string | undefined>): ResolvedEnv | null;
|
|
@@ -20,6 +21,7 @@ interface CamadaOptions {
|
|
|
20
21
|
scriptPath?: string;
|
|
21
22
|
fpPath?: string;
|
|
22
23
|
challenge?: boolean;
|
|
24
|
+
tlsxHeader?: string;
|
|
23
25
|
challengePath?: string;
|
|
24
26
|
snapshotVersion?: SnapshotVersion;
|
|
25
27
|
/** With CAMADA_SERVERLESS=1: handed every per-request event flush and snapshot refresh, so the
|
|
@@ -39,6 +41,7 @@ declare class Camada {
|
|
|
39
41
|
private readonly scriptPathSet;
|
|
40
42
|
private readonly kit;
|
|
41
43
|
private readonly envSource;
|
|
44
|
+
private readonly tlsxHeader;
|
|
42
45
|
private readonly waitUntil?;
|
|
43
46
|
constructor(opts?: CamadaOptions);
|
|
44
47
|
get disabled(): boolean;
|
|
@@ -50,6 +53,14 @@ declare class Camada {
|
|
|
50
53
|
* unidentifiable: the walk would believe whatever the client wrote. So: null, the stance every
|
|
51
54
|
* ip-bound feature already takes (no ip rules, no challenge). */
|
|
52
55
|
private clientIp;
|
|
56
|
+
/** The TLS fingerprint Cloudflare (a Transform Rule) put in the opt-in header, or null. Trusted only
|
|
57
|
+
* in `cidrs` mode and only when the socket peer itself is a NON-loopback address core's cidrs walk
|
|
58
|
+
* trusts, i.e. Cloudflare connects to the app directly. A loopback peer (a local reverse proxy or
|
|
59
|
+
* cloudflared) is never trusted and X-Forwarded-For is never consulted, so the header is not read
|
|
60
|
+
* and tlsx conditions fail open. Trust is core's isTrustedPeer, the same first-hop test the cidrs
|
|
61
|
+
* walk uses. No readable peer (unix listener included) -> null, since core's no-peer walk would
|
|
62
|
+
* trust everyone. */
|
|
63
|
+
private tlsxOf;
|
|
53
64
|
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
54
65
|
* path was configured explicitly. */
|
|
55
66
|
private challengeAction;
|
|
@@ -62,6 +73,11 @@ declare class Camada {
|
|
|
62
73
|
/** True when camada fully handled the request. Synchronous; never throws. */
|
|
63
74
|
handle(req: IncomingMessage, res: ServerResponse): boolean;
|
|
64
75
|
private handleInner;
|
|
76
|
+
/** Mints the request's rid and session (setting the cookie on a first visit), records them on
|
|
77
|
+
* `req.camada` and sets `x-rid`. handle() and serveChallenge() (when handle() did not run) share it. */
|
|
78
|
+
private begin;
|
|
79
|
+
/** Installs the response-finish hook that ships the request's normal capture event. */
|
|
80
|
+
private hookCapture;
|
|
65
81
|
private buildEvent;
|
|
66
82
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
67
83
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
@@ -70,7 +86,7 @@ declare class Camada {
|
|
|
70
86
|
* stream: its `req.body` is used instead, and the request is answered either way. */
|
|
71
87
|
private relayBeacon;
|
|
72
88
|
private challengePassed;
|
|
73
|
-
/** 403 + the proof-of-work page (HTML navigations
|
|
89
|
+
/** 403 + the proof-of-work page (HTML navigations only), plus the
|
|
74
90
|
* `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
|
|
75
91
|
private serveChallengeInner;
|
|
76
92
|
/** The request's existing session, so challenge rows join the session that produced them. */
|
|
@@ -91,7 +107,8 @@ declare class Camada {
|
|
|
91
107
|
private shipUpgrade;
|
|
92
108
|
/** Serve the challenge for this request on demand — for a route the app wants to gate itself
|
|
93
109
|
* (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
|
|
94
|
-
* so the caller renders its own page.
|
|
110
|
+
* so the caller renders its own page. A non-HTML request is never challenged: it gets the
|
|
111
|
+
* `x-camada-challenge: 1` header and a `chs: 1` event, and false, so the app answers. */
|
|
95
112
|
serveChallenge(req: IncomingMessage, res: ServerResponse): boolean;
|
|
96
113
|
/** For HTML templates: the first-party beacon tag with the request's rid. */
|
|
97
114
|
scriptTag(req: IncomingMessage): string;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
EventQueue,
|
|
17
17
|
buildWireEvent,
|
|
18
18
|
resolveClientIp,
|
|
19
|
+
isTrustedPeer,
|
|
19
20
|
hashUserId,
|
|
20
21
|
guarded,
|
|
21
22
|
logRateLimited,
|
|
@@ -23,12 +24,18 @@ import {
|
|
|
23
24
|
safeReturnTo,
|
|
24
25
|
wantsHtml,
|
|
25
26
|
CHALLENGE_COOKIE,
|
|
26
|
-
TAP_NODE
|
|
27
|
+
TAP_NODE,
|
|
28
|
+
REQUEST_ROW_KEYS
|
|
27
29
|
} from "@camada/core";
|
|
30
|
+
import { isIP } from "net";
|
|
28
31
|
import iife from "@camada/browser/iife-string";
|
|
29
32
|
|
|
30
33
|
// src/env.ts
|
|
31
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
|
+
}
|
|
32
39
|
function resolveEnv(env = process.env) {
|
|
33
40
|
const key = parseKey(env.CAMADA_KEY);
|
|
34
41
|
const ingestToken = key?.ingestToken ?? env.CAMADA_TOKEN;
|
|
@@ -42,7 +49,8 @@ function resolveEnv(env = process.env) {
|
|
|
42
49
|
secret: env.CAMADA_KEY || `${ingestToken}.${snapToken}`,
|
|
43
50
|
snapshotUrl: env.CAMADA_SNAPSHOT_URL || `${ingestUrl}/snapshot`,
|
|
44
51
|
serverless: env.CAMADA_SERVERLESS === "1",
|
|
45
|
-
trustedProxy: parseTrustedProxyEnv(env.CAMADA_TRUSTED_PROXY)
|
|
52
|
+
trustedProxy: parseTrustedProxyEnv(env.CAMADA_TRUSTED_PROXY),
|
|
53
|
+
tlsxHeader: parseTlsxHeader(env.CAMADA_TLSX_HEADER)
|
|
46
54
|
};
|
|
47
55
|
}
|
|
48
56
|
|
|
@@ -110,13 +118,9 @@ function writeChallengePage(res, o) {
|
|
|
110
118
|
});
|
|
111
119
|
res.end(challengePage(o));
|
|
112
120
|
}
|
|
113
|
-
function writeChallengeJson(res) {
|
|
114
|
-
res.writeHead(403, { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" });
|
|
115
|
-
res.end('{"error":"challenge_required"}');
|
|
116
|
-
}
|
|
117
121
|
|
|
118
122
|
// package.json
|
|
119
|
-
var version = "0.
|
|
123
|
+
var version = "0.4.0";
|
|
120
124
|
var name = "@camada/node";
|
|
121
125
|
|
|
122
126
|
// src/version.ts
|
|
@@ -127,6 +131,12 @@ var SESSION_COOKIE = "_sfp";
|
|
|
127
131
|
var SCRIPT_PATH = "/_cam/b.js";
|
|
128
132
|
var FP_PATH = "/_cam/fp";
|
|
129
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+/=_-]+$/;
|
|
130
140
|
var ATTACHED = /* @__PURE__ */ Symbol.for("camada.attached");
|
|
131
141
|
var headerReader = (req) => (name2) => {
|
|
132
142
|
const v = req.headers[name2];
|
|
@@ -136,6 +146,7 @@ var splitUrl = (raw) => {
|
|
|
136
146
|
const qi = raw.indexOf("?");
|
|
137
147
|
return qi === -1 ? { path: raw, query: "" } : { path: raw.slice(0, qi), query: raw.slice(qi) };
|
|
138
148
|
};
|
|
149
|
+
var isPage = (req) => wantsHtml(req.headers.accept ?? null, req.headers["sec-fetch-dest"] ?? null);
|
|
139
150
|
function pathAndQuery(req) {
|
|
140
151
|
return splitUrl(req.originalUrl || req.url || "/");
|
|
141
152
|
}
|
|
@@ -173,6 +184,27 @@ function keepSessionCookie(res, cookie) {
|
|
|
173
184
|
};
|
|
174
185
|
res.setHeader("set-cookie", cookie);
|
|
175
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
|
+
}
|
|
176
208
|
var Camada = class {
|
|
177
209
|
env;
|
|
178
210
|
snap = null;
|
|
@@ -187,6 +219,7 @@ var Camada = class {
|
|
|
187
219
|
scriptPathSet;
|
|
188
220
|
kit = null;
|
|
189
221
|
envSource;
|
|
222
|
+
tlsxHeader;
|
|
190
223
|
waitUntil;
|
|
191
224
|
constructor(opts = {}) {
|
|
192
225
|
this.envSource = opts.env ?? process.env;
|
|
@@ -199,6 +232,7 @@ var Camada = class {
|
|
|
199
232
|
this.challengePathSet = opts.challengePath != null;
|
|
200
233
|
this.scriptPathSet = opts.scriptPath != null;
|
|
201
234
|
this.env = resolveEnv(this.envSource);
|
|
235
|
+
this.tlsxHeader = parseTlsxHeader(opts.tlsxHeader) ?? this.env?.tlsxHeader ?? null;
|
|
202
236
|
if (!this.env) return;
|
|
203
237
|
if (this.envSource.CAMADA_DISABLED === "1") return;
|
|
204
238
|
this.snap = new SnapshotClient({
|
|
@@ -232,6 +266,24 @@ var Camada = class {
|
|
|
232
266
|
if (!peer && req.socket && !onUnixListener(req.socket)) return null;
|
|
233
267
|
return resolveClientIp(peer ?? null, req.headers["x-forwarded-for"], this.trustedProxy());
|
|
234
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
|
+
}
|
|
235
287
|
/** Where the challenge form posts: under the mount prefix, so it reaches camada, unless the
|
|
236
288
|
* path was configured explicitly. */
|
|
237
289
|
challengeAction(req) {
|
|
@@ -269,7 +321,7 @@ var Camada = class {
|
|
|
269
321
|
const { path, query } = pathAndQuery(req);
|
|
270
322
|
const ip = this.clientIp(req);
|
|
271
323
|
req.camadaPrefix = mountPrefix(req);
|
|
272
|
-
const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req) });
|
|
324
|
+
const v = this.snap.verdict({ ip, path, ua: req.headers["user-agent"], header: headerReader(req), tlsx: this.tlsxOf(req) });
|
|
273
325
|
if (v.block) {
|
|
274
326
|
const headers = { "x-block-reason": v.reason ?? "", "x-block-version": v.version ?? "", "content-type": "text/plain", "cache-control": "no-store" };
|
|
275
327
|
if (v.rule) headers["x-block-rule"] = v.rule;
|
|
@@ -288,8 +340,11 @@ var Camada = class {
|
|
|
288
340
|
return true;
|
|
289
341
|
}
|
|
290
342
|
if (v.challenge && !this.challengePassed(req, ip)) {
|
|
291
|
-
|
|
292
|
-
|
|
343
|
+
if (isPage(req)) {
|
|
344
|
+
this.serveChallengeInner(req, res, ip, path + query);
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
req.camadaSignalled = true;
|
|
293
348
|
}
|
|
294
349
|
}
|
|
295
350
|
if (this.beaconEnabled()) {
|
|
@@ -303,50 +358,64 @@ var Camada = class {
|
|
|
303
358
|
return true;
|
|
304
359
|
}
|
|
305
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) {
|
|
306
373
|
const rid = randomUUID();
|
|
307
374
|
let sid = cookieValue(req.headers.cookie || "", SESSION_COOKIE);
|
|
308
|
-
const
|
|
375
|
+
const ns = !sid;
|
|
309
376
|
if (!sid) {
|
|
310
377
|
sid = randomUUID();
|
|
311
378
|
const https = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
|
|
312
379
|
keepSessionCookie(res, `${SESSION_COOKIE}=${sid}; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax${https ? "; Secure" : ""}`);
|
|
313
380
|
}
|
|
314
|
-
req.camada = { rid, ip, sid };
|
|
315
381
|
res.setHeader("x-rid", rid);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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);
|
|
341
407
|
}
|
|
342
408
|
buildEvent(req, path, query, ip, o) {
|
|
343
409
|
const raw = req.rawHeaders || [];
|
|
344
410
|
const headers = [];
|
|
345
411
|
for (let i = 0; i + 1 < raw.length; i += 2) headers.push([raw[i], raw[i + 1]]);
|
|
346
|
-
|
|
412
|
+
const ev = buildWireEvent(
|
|
347
413
|
{ method: req.method || "GET", host: req.headers.host || "", path, query, headers, ip, httpVersion: req.httpVersion },
|
|
348
414
|
{ tap: TAP_NODE, rid: o.rid, sid: o.sid, newSession: o.newSession }
|
|
349
415
|
);
|
|
416
|
+
const tlsx = this.tlsxOf(req);
|
|
417
|
+
if (tlsx) ev.tlsx = tlsx;
|
|
418
|
+
return ev;
|
|
350
419
|
}
|
|
351
420
|
/** Reads the beacon POST (≤32 KB), answers 204 immediately, and queues the beacon as a `sig: 1`
|
|
352
421
|
* row with the trusted-proxy-resolved client IP: it rides the next event batch, so the analyst
|
|
@@ -358,7 +427,9 @@ var Camada = class {
|
|
|
358
427
|
res.writeHead(204, { "cache-control": "no-store" });
|
|
359
428
|
res.end();
|
|
360
429
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Buffer.isBuffer(parsed)) return;
|
|
361
|
-
|
|
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);
|
|
362
433
|
};
|
|
363
434
|
const fromText = (text) => {
|
|
364
435
|
try {
|
|
@@ -402,15 +473,11 @@ var Camada = class {
|
|
|
402
473
|
challengePassed(req, ip) {
|
|
403
474
|
return !!this.kit?.tokenValid(ip, Date.now(), cookieValue(req.headers.cookie || "", CHALLENGE_COOKIE));
|
|
404
475
|
}
|
|
405
|
-
/** 403 + the proof-of-work page (HTML navigations
|
|
476
|
+
/** 403 + the proof-of-work page (HTML navigations only), plus the
|
|
406
477
|
* `blk: "challenge"` event — a served challenge is reported like a block (contract §D2). */
|
|
407
478
|
serveChallengeInner(req, res, ip, target) {
|
|
408
479
|
const to = safeReturnTo(target);
|
|
409
|
-
|
|
410
|
-
writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
|
|
411
|
-
} else {
|
|
412
|
-
writeChallengeJson(res);
|
|
413
|
-
}
|
|
480
|
+
writeChallengePage(res, { nonce: this.kit.nonce(ip, Date.now()), action: this.challengeAction(req), to });
|
|
414
481
|
guarded(() => {
|
|
415
482
|
const qi = target.indexOf("?");
|
|
416
483
|
const ev = this.buildEvent(
|
|
@@ -492,14 +559,25 @@ var Camada = class {
|
|
|
492
559
|
}
|
|
493
560
|
/** Serve the challenge for this request on demand — for a route the app wants to gate itself
|
|
494
561
|
* (the example's /challenge-me). Returns false when the client already holds a valid `_cch`,
|
|
495
|
-
* 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. */
|
|
496
564
|
serveChallenge(req, res) {
|
|
497
565
|
return guarded(() => {
|
|
498
566
|
if (this.disabled || !this.kit || !this.queue) return false;
|
|
499
567
|
const ip = this.clientIp(req);
|
|
500
568
|
if (!ip || this.challengePassed(req, ip)) return false;
|
|
501
|
-
|
|
502
|
-
const { path, query } = pathAndQuery(
|
|
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;
|
|
503
581
|
this.serveChallengeInner(req, res, ip, path + query);
|
|
504
582
|
return true;
|
|
505
583
|
}, false);
|
|
@@ -577,4 +655,4 @@ export {
|
|
|
577
655
|
configure,
|
|
578
656
|
index_default
|
|
579
657
|
};
|
|
580
|
-
//# sourceMappingURL=chunk-
|
|
658
|
+
//# sourceMappingURL=chunk-44FSZJ5F.js.map
|