quico 0.4.0 → 0.8.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/src/ja4h.js ADDED
@@ -0,0 +1,256 @@
1
+ /*
2
+ * quico: a full HTTP stack in JavaScript
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://github.com/colocohen/quico
10
+ */
11
+
12
+
13
+
14
+ import { sha12 } from './http_common.js';
15
+
16
+
17
+ // ============================================================
18
+ // JA4H — HTTP client fingerprint.
19
+ //
20
+ // The companion to the TLS fingerprints lemon-tls produces. JA4 says which
21
+ // TLS stack built the ClientHello; JA4H says which HTTP client sent the
22
+ // request. Together they catch a class of thing neither finds alone: a
23
+ // request whose TLS says "Chrome 131" and whose headers say "python-requests"
24
+ // is not a browser, however convincing its User-Agent.
25
+ //
26
+ // Format: a_b_c_d
27
+ //
28
+ // a ge20cn11enus method, version, cookie, referer, header count, lang
29
+ // b 3a41b52c00e1 hash of the header NAMES, in the order sent
30
+ // c 000000000000 hash of the cookie names, sorted
31
+ // d 000000000000 hash of the cookie name=value pairs, sorted
32
+ //
33
+ // The sections are separately useful, which is the point of the a_b_c_d
34
+ // shape. FoxIO's own description: JA4H_c identifies the SITE (every Plex
35
+ // server sets the same cookie names, so every client of one produces the same
36
+ // _c), while JA4H_d identifies the USER (their values differ). That makes _d
37
+ // a per-user handle that contains no personal data — a tracking key that is
38
+ // a hash of a hash, rather than a logged identity.
39
+ //
40
+ // A caution worth stating plainly: _d IS a user identifier, GDPR-friendly in
41
+ // form or not. Log it if you need per-user correlation, and think before you
42
+ // keep it longer than the session it describes.
43
+ //
44
+ // The published spec (JA4H.md) is 278 bytes and documents ONLY the
45
+ // header-count rule. Everything else here is reconstructed from FoxIO's
46
+ // reference implementations — chiefly the F5 iRule, which carries their
47
+ // copyright — and then verified against a published fingerprint rather than
48
+ // trusted.
49
+ //
50
+ // That verification mattered, because FoxIO's own implementations disagree
51
+ // about _b: the Rust CLI documents `--original-order` as DISABLING the
52
+ // sorting of headers, implying it sorts by default, while the iRule appends
53
+ // header names in the order received and never sorts them.
54
+ //
55
+ // The disagreement was settled by arithmetic. The Sliver C2 analysis
56
+ // publishes po11cn050000_bb52516416a2_eb49a3237520 for a request whose five
57
+ // counted headers are Host, User-Agent, Content-Length,
58
+ // Upgrade-Insecure-Requests, Accept-Encoding. Hashing every plausible
59
+ // variant of that list gives exactly one match:
60
+ //
61
+ // original order, original case -> bb52516416a2 <-- published value
62
+ // original order, lowercased -> 4637073cda60
63
+ // sorted, original case -> a56dc1e0e2e6
64
+ // sorted, lowercased -> 7c182098df7a
65
+ //
66
+ // So: order as received, case as received. Which is also the reading that
67
+ // makes sense — ORDER is most of the signal, since browsers emit a stable
68
+ // header order and HTTP libraries emit a different, equally stable one, and
69
+ // sorting would discard exactly that.
70
+ // ============================================================
71
+
72
+ var EMPTY_HASH = '000000000000';
73
+
74
+
75
+ // Two lowercase characters. Anything not in this list is "xx" — which is
76
+ // itself a signal, since browsers only ever send these.
77
+ var METHOD_CODES = {
78
+ GET: 'ge', POST: 'po', HEAD: 'he', PUT: 'pu', DELETE: 'de',
79
+ CONNECT: 'co', OPTIONS: 'op', TRACE: 'tr', PATCH: 'pa'
80
+ };
81
+
82
+ function methodCode(method) {
83
+ var m = String(method || '').toUpperCase();
84
+ return METHOD_CODES[m] || 'xx';
85
+ }
86
+
87
+ function versionCode(httpVersion) {
88
+ var v = String(httpVersion || '');
89
+ if (v === '1.0' || v === '10') return '10';
90
+ if (v === '1.1' || v === '11') return '11';
91
+ if (v === '2.0' || v === '2' || v === '20') return '20';
92
+ if (v === '3.0' || v === '3' || v === '30') return '30';
93
+ return '00';
94
+ }
95
+
96
+ /**
97
+ * Four characters from Accept-Language, or "0000" when absent.
98
+ *
99
+ * "en-US,en;q=0.9,he;q=0.8" -> "enus": take the first language, drop the
100
+ * q-value, remove the hyphen, lowercase, then truncate or zero-pad to four.
101
+ *
102
+ * Four characters is deliberately coarse. It separates populations — a client
103
+ * with no Accept-Language at all stands out sharply, and most HTTP libraries
104
+ * send none — without turning a language preference into an identifier.
105
+ */
106
+ function languageCode(acceptLanguage) {
107
+ if (!acceptLanguage) return '0000';
108
+
109
+ // Bounded before anything else. Accept-Language is attacker-controlled and
110
+ // unbounded; the reference implementation truncates to 64 bytes first for
111
+ // exactly that reason.
112
+ var v = String(acceptLanguage).slice(0, 64);
113
+
114
+ // Hyphens removed and semicolons turned into commas BEFORE splitting, so
115
+ // that the q-value separator and the language separator are treated alike.
116
+ // "en-US,en;q=0.9" -> "enus,en,q=09" -> first token "enus".
117
+ v = v.replace(/-/g, '').replace(/;/g, ',').toLowerCase();
118
+ var first = v.split(',')[0].slice(0, 4);
119
+ if (first.length === 0) return '0000';
120
+
121
+ // Padded by OVERWRITING the leading characters of "0000", so a 2-character
122
+ // language becomes "he00" rather than "00he".
123
+ return first + '0'.repeat(4 - first.length);
124
+ }
125
+
126
+ /** Cookie names and values, in the order sent. */
127
+ function parseCookies(cookieHeader) {
128
+ var out = [];
129
+ if (!cookieHeader) return out;
130
+ var parts = String(cookieHeader).split(';');
131
+ for (var i = 0; i < parts.length; i++) {
132
+ var p = parts[i].trim();
133
+ if (!p) continue;
134
+ var eq = p.indexOf('=');
135
+ if (eq < 0) out.push({ name: p, value: '' });
136
+ else out.push({ name: p.slice(0, eq).trim(), value: p.slice(eq + 1).trim() });
137
+ }
138
+ return out;
139
+ }
140
+
141
+
142
+ /**
143
+ * Compute JA4H for one request.
144
+ *
145
+ * Pass a quico (or node:http) IncomingMessage, or a plain object with
146
+ * `method`, `httpVersion`, `headers` and optionally `rawHeaders`.
147
+ *
148
+ * rawHeaders matters and is used when present: it preserves the ORDER the
149
+ * client sent, and order is most of what distinguishes a browser from a
150
+ * library. A `headers` object cannot preserve it reliably — repeated fields
151
+ * are folded and insertion order is an implementation detail — so passing only
152
+ * `headers` still produces a valid fingerprint, just a weaker one.
153
+ *
154
+ * Returns { hash, raw, a, b, c, d } so a caller can match on the whole thing
155
+ * or on any section: `_ab` identifies the client software, `_c` the site, `_d`
156
+ * the user.
157
+ */
158
+ function ja4h(req) {
159
+ var headers = (req && req.headers) || {};
160
+ var method = req && (req.method || headers[':method']);
161
+ var version = req && req.httpVersion;
162
+
163
+ // Header names in the order the client sent them.
164
+ // ORIGINAL CASE, not lowercased.
165
+ //
166
+ // FoxIO's own iRule appends `${hname}` — the name as sent — and lowercases
167
+ // only for its comparisons. Casing is real signal: HTTP/1.1 clients choose
168
+ // it freely and choose differently ("User-Agent" from a browser,
169
+ // "user-agent" from many libraries), so folding it throws away a
170
+ // distinguishing bit. HTTP/2 and HTTP/3 require lowercase on the wire, so
171
+ // there is nothing to lose there either way.
172
+ var names = [];
173
+ if (req && Array.isArray(req.rawHeaders) && req.rawHeaders.length >= 2) {
174
+ for (var i = 0; i < req.rawHeaders.length; i += 2) {
175
+ names.push(String(req.rawHeaders[i]));
176
+ }
177
+ } else {
178
+ for (var k in headers) {
179
+ if (Object.prototype.hasOwnProperty.call(headers, k)) names.push(String(k));
180
+ }
181
+ }
182
+
183
+ // Pseudo-headers are HTTP/2 and HTTP/3 framing, not client-chosen fields,
184
+ // and counting them would make the same client fingerprint differently
185
+ // depending on which version carried the request — the opposite of what a
186
+ // client fingerprint is for.
187
+ names = names.filter(function (n) { return n.charAt(0) !== ':'; });
188
+
189
+ function lower(n) { return String(n).toLowerCase(); }
190
+ var lowered = names.map(lower);
191
+
192
+ var hasCookie = lowered.indexOf('cookie') !== -1;
193
+ // "referer" only — the misspelling is the one in the HTTP spec, and
194
+ // "referrer" is a different header that a client would have invented.
195
+ var hasReferer = lowered.indexOf('referer') !== -1;
196
+
197
+ // Cookie and Referer are excluded from both the count and the hash: they are
198
+ // properties of the SITE and the navigation, not of the client. Their
199
+ // presence is already recorded as the c/r characters, and the cookies
200
+ // themselves get their own sections.
201
+ // "cookie*", not just "cookie": the reference implementation matches the
202
+ // prefix, which also excludes Cookie2 and any other cookie-carrying variant.
203
+ var counted = names.filter(function (n) {
204
+ var l = lower(n);
205
+ return l.indexOf('cookie') !== 0 && l !== 'referer';
206
+ });
207
+
208
+ var count = counted.length;
209
+ if (count > 99) count = 99;
210
+ var countStr = (count < 10 ? '0' : '') + count;
211
+
212
+ var a = methodCode(method) +
213
+ versionCode(version) +
214
+ (hasCookie ? 'c' : 'n') +
215
+ (hasReferer ? 'r' : 'n') +
216
+ countStr +
217
+ languageCode(headers['accept-language']);
218
+
219
+ // Order preserved, not sorted. Browsers emit a stable header order and HTTP
220
+ // libraries emit a different, equally stable one, so the order carries more
221
+ // signal than the set does.
222
+ var bRaw = counted.join(',');
223
+ var b = counted.length ? sha12(bRaw) : EMPTY_HASH;
224
+
225
+ // Keyed off hasCookie — the same flag that produced the 'c' character —
226
+ // rather than off whether any cookie parsed. A Cookie header that is present
227
+ // but empty still means "this client sends cookies", and the two sections
228
+ // must agree with the character or the fingerprint contradicts itself.
229
+ var cookies = hasCookie ? parseCookies(headers['cookie']) : [];
230
+
231
+ // Sorted, unlike the header names: cookie order is not under the client's
232
+ // control in the way header order is, so sorting removes noise rather than
233
+ // signal.
234
+ var cookieNames = cookies.map(function (x) { return x.name; }).sort();
235
+ var cRaw = cookieNames.join(',');
236
+ var c = hasCookie ? sha12(cRaw) : EMPTY_HASH;
237
+
238
+ var cookiePairs = cookies.map(function (x) { return x.name + '=' + x.value; }).sort();
239
+ var dRaw = cookiePairs.join(',');
240
+ var d = hasCookie ? sha12(dRaw) : EMPTY_HASH;
241
+
242
+ return {
243
+ hash: a + '_' + b + '_' + c + '_' + d,
244
+
245
+ // The raw form deliberately STOPS at the cookie names and omits their
246
+ // values, which is what the reference implementation does and for a reason
247
+ // worth repeating: cookie values are session tokens. The _d hash above
248
+ // already captures that dimension, so printing the values into a log adds
249
+ // nothing but a credential leak.
250
+ raw: a + '_' + bRaw + '_' + cRaw,
251
+ a: a, b: b, c: c, d: d
252
+ };
253
+ }
254
+
255
+
256
+ export { ja4h, languageCode, methodCode, versionCode };