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/README.md +1987 -160
- package/index.d.ts +1034 -77
- package/index.js +59 -36
- package/package.json +8 -5
- package/src/agent.js +306 -36
- package/src/client.js +447 -120
- package/src/client_profile.js +266 -0
- package/src/connect_udp.js +578 -0
- package/src/connect_udp_client.js +779 -0
- package/src/crypto.js +240 -140
- package/src/h1.js +953 -0
- package/src/h1_client.js +731 -0
- package/src/h1_server.js +666 -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 +911 -0
- package/src/h3.js +1986 -520
- package/src/h3_client.js +484 -46
- package/src/h3_server.js +1448 -98
- package/src/hpack.js +455 -0
- package/src/hpack_primitives.js +559 -0
- package/src/http_common.js +908 -0
- package/src/ja4h.js +256 -0
- package/src/qpack.js +555 -0
- package/src/quic_connection.js +6702 -665
- package/src/quic_server.js +858 -82
- package/src/quic_socket.js +134 -9
- package/src/quic_version.js +143 -0
- package/src/server.js +1097 -125
- package/src/server_stats.js +93 -0
- package/src/streams.js +542 -43
- package/src/tcp_listener.js +568 -0
- package/src/tls_bridge.js +478 -73
- package/src/tls_options.js +108 -0
- package/src/transport.js +455 -46
- package/src/utils.js +94 -12
- package/src/webtransport.js +784 -417
- package/src/ws.js +744 -0
- package/src/ws_client.js +621 -0
- package/src/ws_server.js +815 -0
- package/src/ws_stream.js +250 -0
- package/src/wt_stream.js +1075 -0
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
// Cross-layer consistency.
|
|
15
|
+
//
|
|
16
|
+
// Four fingerprints are now available for a request — JA4 for the TLS stack,
|
|
17
|
+
// JA4H for the HTTP client, h2Fingerprint for the HTTP/2 implementation, and
|
|
18
|
+
// the User-Agent the caller typed. Each on its own is a label. Their value is
|
|
19
|
+
// in DISAGREEING.
|
|
20
|
+
//
|
|
21
|
+
// The reason this works is that the layers are produced by different parts of
|
|
22
|
+
// a real client and cannot be changed independently:
|
|
23
|
+
//
|
|
24
|
+
// the TLS fingerprint comes from the crypto library that built the
|
|
25
|
+
// ClientHello — BoringSSL in Chrome, NSS in Firefox, OpenSSL in curl
|
|
26
|
+
//
|
|
27
|
+
// the HTTP/2 fingerprint comes from the network stack that opened the
|
|
28
|
+
// connection, which is a different component again
|
|
29
|
+
//
|
|
30
|
+
// the User-Agent comes from a string constant that anyone can edit
|
|
31
|
+
//
|
|
32
|
+
// A scraper can change the last one for free. Changing the first two means
|
|
33
|
+
// reimplementing TLS and HTTP/2 to match a specific browser build — possible,
|
|
34
|
+
// and the reason curl-impersonate and similar tools exist, but expensive and
|
|
35
|
+
// fragile in a way a header edit is not.
|
|
36
|
+
//
|
|
37
|
+
// WHAT THIS IS NOT
|
|
38
|
+
//
|
|
39
|
+
// Not an identifier. Every Chrome of the same version on the same platform
|
|
40
|
+
// shares one fingerprint, so this describes a population, never a person.
|
|
41
|
+
//
|
|
42
|
+
// Not a verdict. It reports observations and lets the application decide.
|
|
43
|
+
// The right response to "claims Chrome, does not look like Chrome" is almost
|
|
44
|
+
// never a block: a corporate TLS-inspecting proxy produces exactly that
|
|
45
|
+
// signature, and so does an old browser behind a CDN. Rate-limit, challenge,
|
|
46
|
+
// log — and reserve blocking for cases where a false positive is cheap.
|
|
47
|
+
//
|
|
48
|
+
// Not stable across upgrades. Chrome changes its TLS profile between
|
|
49
|
+
// versions. Anything matching an exact hash will break on the next release;
|
|
50
|
+
// match on prefixes and on structure instead, which is what the checks below
|
|
51
|
+
// do.
|
|
52
|
+
// ============================================================
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/** Signals, in rough order of how hard they are to fake. */
|
|
56
|
+
var SEVERITY = { INFO: 0, WEAK: 1, STRONG: 2 };
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
// The JA4 prefix a browser produces, up to the counts, which drift between
|
|
60
|
+
// versions. `t13d` alone says TLS 1.3 over TCP with a domain — true of almost
|
|
61
|
+
// every modern client — so the cipher COUNT is what separates them: browsers
|
|
62
|
+
// offer a curated list, libraries offer whatever their TLS build supports.
|
|
63
|
+
var BROWSER_CIPHER_COUNTS = {
|
|
64
|
+
chrome: [15, 16],
|
|
65
|
+
firefox: [15, 16, 17],
|
|
66
|
+
safari: [12, 13, 14]
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Over QUIC the same browsers offer far fewer, and it is not a different
|
|
70
|
+
// profile so much as a different requirement: RFC 9001 mandates TLS 1.3, so
|
|
71
|
+
// there is nothing older to stay compatible with and the list collapses to
|
|
72
|
+
// the three TLS 1.3 suites. Chrome offers 15 over TCP and 3 over QUIC — the
|
|
73
|
+
// same browser, and comparing one against the other reports a mismatch that
|
|
74
|
+
// is not there.
|
|
75
|
+
var BROWSER_CIPHER_COUNTS_QUIC = {
|
|
76
|
+
chrome: [3],
|
|
77
|
+
firefox: [3],
|
|
78
|
+
safari: [3]
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Pseudo-header order is set deep in each browser's network stack and has not
|
|
82
|
+
// changed in years. Most HTTP libraries use an order matching no browser at
|
|
83
|
+
// all, which is why this is the single most useful h2 signal.
|
|
84
|
+
var PSEUDO_ORDER = {
|
|
85
|
+
chrome: 'm,a,s,p',
|
|
86
|
+
firefox: 'm,p,a,s',
|
|
87
|
+
safari: 'm,s,p,a'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
/** Which browser family a User-Agent CLAIMS. Claims only — never trust it. */
|
|
92
|
+
function claimedBrowser(ua) {
|
|
93
|
+
ua = String(ua || '');
|
|
94
|
+
// Order matters: Edge and Opera both carry "Chrome" in their UA, and Chrome
|
|
95
|
+
// carries "Safari". Check the more specific strings first.
|
|
96
|
+
if (/\bEdg\//.test(ua)) return 'edge';
|
|
97
|
+
if (/\bOPR\//.test(ua)) return 'opera';
|
|
98
|
+
if (/\bFirefox\//.test(ua)) return 'firefox';
|
|
99
|
+
if (/\bChrome\//.test(ua)) return 'chrome';
|
|
100
|
+
if (/\bSafari\//.test(ua) && /\bVersion\//.test(ua)) return 'safari';
|
|
101
|
+
if (/curl\//i.test(ua)) return 'curl';
|
|
102
|
+
if (/python|go-http|java|okhttp|axios|node-fetch|wget/i.test(ua)) return 'library';
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Chrome, Edge and Opera are all Chromium: same TLS and HTTP/2 profile. */
|
|
107
|
+
function family(name) {
|
|
108
|
+
if (name === 'edge' || name === 'opera') return 'chrome';
|
|
109
|
+
return name;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* profileRequest(req) — observations about one request, and what they add up
|
|
115
|
+
* to.
|
|
116
|
+
*
|
|
117
|
+
* Returns:
|
|
118
|
+
*
|
|
119
|
+
* {
|
|
120
|
+
* claimed: 'chrome', what the User-Agent says
|
|
121
|
+
* protocol: '2.0',
|
|
122
|
+
* fingerprints: { ja4, ja4s, h2, h2raw, h3, h3raw },
|
|
123
|
+
* signals: [ { id, severity, detail } ],
|
|
124
|
+
* suspicion: 0..100
|
|
125
|
+
* }
|
|
126
|
+
*
|
|
127
|
+
* `suspicion` is a convenience, not a truth. It exists so a caller can set one
|
|
128
|
+
* threshold rather than reading every signal, and the signals are there so
|
|
129
|
+
* that a caller who cares can ignore the number.
|
|
130
|
+
*/
|
|
131
|
+
function profileRequest(req) {
|
|
132
|
+
var sock = req.socket || {};
|
|
133
|
+
var ua = req.headers && req.headers['user-agent'];
|
|
134
|
+
var claimed = claimedBrowser(ua);
|
|
135
|
+
var fam = family(claimed);
|
|
136
|
+
|
|
137
|
+
// TLS fingerprints from the socket, by TLSSocket's names: getJA4() and
|
|
138
|
+
// getJA4S(), each { hash, raw } or null. Over TCP that is the TLS socket;
|
|
139
|
+
// over QUIC, the connection's socket stand-in, which answers the same.
|
|
140
|
+
// (This read getFingerprints(), a method no socket has any more — so JA4
|
|
141
|
+
// was always missing, and every TLS check below silently skipped.)
|
|
142
|
+
var ja4r = typeof sock.getJA4 === 'function' ? sock.getJA4() : null;
|
|
143
|
+
var ja4sr = typeof sock.getJA4S === 'function' ? sock.getJA4S() : null;
|
|
144
|
+
var ja4 = ja4r && ja4r.hash ? ja4r.hash : null;
|
|
145
|
+
var h2 = (req.h2Fingerprint ? req.h2Fingerprint() : null) || null;
|
|
146
|
+
// HTTP/3's protocol fingerprint is reported, but not yet checked against
|
|
147
|
+
// browsers: the expected values below are HTTP/2's, measured from browser
|
|
148
|
+
// HTTP/2 stacks, and HTTP/3 is a different stack in every browser.
|
|
149
|
+
var h3 = (req.h3Fingerprint ? req.h3Fingerprint() : null) || null;
|
|
150
|
+
|
|
151
|
+
var signals = [];
|
|
152
|
+
function signal(id, severity, detail) {
|
|
153
|
+
signals.push({ id: id, severity: severity, detail: detail });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- No TLS fingerprint at all ----
|
|
157
|
+
if (!ja4) {
|
|
158
|
+
// Not suspicious by itself: a request arriving over plain HTTP, or through
|
|
159
|
+
// a proxy that terminated TLS upstream, has nothing to fingerprint. It
|
|
160
|
+
// only means the strongest signal is unavailable.
|
|
161
|
+
signal('no-tls-fingerprint', SEVERITY.INFO, 'no TLS fingerprint available');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---- A browser must offer ALPN ----
|
|
165
|
+
if (ja4 && claimed && fam !== 'curl' && fam !== 'library') {
|
|
166
|
+
var alpnPart = ja4.split('_')[0].slice(8);
|
|
167
|
+
if (alpnPart === '00') {
|
|
168
|
+
// Browsers always offer ALPN — they have to, since HTTP/2 requires it.
|
|
169
|
+
// A client claiming to be one that offers none is not one.
|
|
170
|
+
signal('browser-without-alpn', SEVERITY.STRONG,
|
|
171
|
+
'claims ' + claimed + ' but offered no ALPN');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- Cipher count against the claimed browser ----
|
|
176
|
+
// The first character of JA4 is the transport: 'q' for QUIC, 't' for TCP.
|
|
177
|
+
// Which table applies follows from it.
|
|
178
|
+
var overQuic = ja4 && ja4.charAt(0) === 'q';
|
|
179
|
+
var cipherTable = overQuic ? BROWSER_CIPHER_COUNTS_QUIC : BROWSER_CIPHER_COUNTS;
|
|
180
|
+
|
|
181
|
+
if (ja4 && cipherTable[fam]) {
|
|
182
|
+
var count = parseInt(ja4.slice(4, 6), 10);
|
|
183
|
+
var expected = cipherTable[fam];
|
|
184
|
+
if (!isNaN(count) && expected.indexOf(count) === -1) {
|
|
185
|
+
// Deliberately WEAK, not STRONG. The counts drift between browser
|
|
186
|
+
// versions, and a corporate TLS-inspecting proxy rewrites the
|
|
187
|
+
// ClientHello entirely — both produce a mismatch with nothing wrong.
|
|
188
|
+
signal('cipher-count-mismatch', SEVERITY.WEAK,
|
|
189
|
+
'claims ' + claimed + ' over ' + (overQuic ? 'QUIC' : 'TCP') +
|
|
190
|
+
' (expected ' + expected.join(' or ') + ' ciphers) but offered ' + count);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- Pseudo-header order: the strongest HTTP/2 signal ----
|
|
195
|
+
if (h2 && h2.ps && h2.ps !== '0' && PSEUDO_ORDER[fam]) {
|
|
196
|
+
if (h2.ps !== PSEUDO_ORDER[fam]) {
|
|
197
|
+
// This one is worth trusting. The order is a constant in each browser's
|
|
198
|
+
// source, unchanged for years, and no proxy rewrites it — an
|
|
199
|
+
// intermediary that re-encodes HTTP/2 would have to reorder
|
|
200
|
+
// pseudo-headers deliberately, which none do.
|
|
201
|
+
signal('pseudo-header-order', SEVERITY.STRONG,
|
|
202
|
+
'claims ' + claimed + ' (expects ' + PSEUDO_ORDER[fam] +
|
|
203
|
+
') but sent ' + h2.ps);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- A browser opens a large connection window ----
|
|
208
|
+
if (h2 && !overQuic && claimed && PSEUDO_ORDER[fam]) {
|
|
209
|
+
if (h2.wu === '0') {
|
|
210
|
+
// Browsers widen the connection window immediately because they fetch
|
|
211
|
+
// dozens of resources at once. Libraries keep the 65535 default and send
|
|
212
|
+
// no WINDOW_UPDATE at all.
|
|
213
|
+
signal('no-window-update', SEVERITY.WEAK,
|
|
214
|
+
'claims ' + claimed + ' but sent no connection WINDOW_UPDATE');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- Client Hints ----
|
|
219
|
+
if (fam === 'chrome' && req.headers && !req.headers['sec-ch-ua']) {
|
|
220
|
+
// Chromium has sent sec-ch-ua on every secure request since version 89.
|
|
221
|
+
// Absent, on a request claiming a recent Chrome, means the headers were
|
|
222
|
+
// written by something that only knows about User-Agent.
|
|
223
|
+
signal('missing-client-hints', SEVERITY.WEAK,
|
|
224
|
+
'claims Chrome but sent no sec-ch-ua');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- Fetch metadata ----
|
|
228
|
+
if (PSEUDO_ORDER[fam] && req.headers && !req.headers['sec-fetch-mode'] &&
|
|
229
|
+
req.method === 'GET') {
|
|
230
|
+
signal('missing-fetch-metadata', SEVERITY.WEAK,
|
|
231
|
+
'claims ' + claimed + ' but sent no Sec-Fetch-* headers');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- No User-Agent at all ----
|
|
235
|
+
if (!ua) {
|
|
236
|
+
signal('no-user-agent', SEVERITY.WEAK, 'no User-Agent header');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A blunt sum, capped. Deliberately blunt: a caller that wants nuance should
|
|
240
|
+
// read `signals`, and one that does not is better served by a single number
|
|
241
|
+
// than by a formula it will not tune.
|
|
242
|
+
var suspicion = 0;
|
|
243
|
+
for (var i = 0; i < signals.length; i++) {
|
|
244
|
+
if (signals[i].severity === SEVERITY.STRONG) suspicion += 45;
|
|
245
|
+
else if (signals[i].severity === SEVERITY.WEAK) suspicion += 15;
|
|
246
|
+
}
|
|
247
|
+
if (suspicion > 100) suspicion = 100;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
claimed: claimed,
|
|
251
|
+
protocol: req.httpVersion,
|
|
252
|
+
fingerprints: {
|
|
253
|
+
ja4: ja4,
|
|
254
|
+
ja4s: ja4sr && ja4sr.hash ? ja4sr.hash : null,
|
|
255
|
+
h2: h2 ? h2.hash : null,
|
|
256
|
+
h2raw: h2 ? h2.raw : null,
|
|
257
|
+
h3: h3 ? h3.hash : null,
|
|
258
|
+
h3raw: h3 ? h3.raw : null
|
|
259
|
+
},
|
|
260
|
+
signals: signals,
|
|
261
|
+
suspicion: suspicion
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
export { profileRequest, claimedBrowser, SEVERITY, PSEUDO_ORDER };
|