quico 0.3.0 → 0.7.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 +2489 -147
- package/index.js +56 -34
- package/package.json +3 -3
- package/src/agent.js +364 -36
- package/src/client.js +474 -118
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +770 -0
- package/src/crypto.js +257 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +624 -0
- package/src/h2.js +1325 -0
- package/src/h2_client.js +675 -0
- package/src/h2_fingerprint.js +403 -0
- package/src/h2_frames.js +582 -0
- package/src/h2_server.js +862 -0
- package/src/h3.js +1966 -469
- package/src/h3_client.js +476 -48
- package/src/h3_server.js +1349 -94
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +520 -0
- package/src/http_common.js +901 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6666 -632
- package/src/quic_server.js +822 -69
- package/src/quic_socket.js +183 -7
- package/src/quic_version.js +143 -0
- package/src/server.js +843 -115
- package/src/server_stats.js +93 -0
- package/src/streams.js +376 -36
- package/src/tcp_listener.js +557 -0
- package/src/tls_bridge.js +516 -75
- package/src/tls_options.js +108 -0
- package/src/transport.js +541 -39
- package/src/utils.js +94 -12
- package/src/webtransport.js +433 -436
- package/src/ws.js +624 -0
- package/src/ws_client.js +641 -0
- package/src/ws_server.js +833 -0
- package/src/wt_stream.js +618 -0
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 };
|