tunnelfetch 1.0.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/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
package/src/transport.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Transport assembly: URL in, byte duplex out.
|
|
2
|
+
//
|
|
3
|
+
// This is where the proxy tunnel and the userland TLS stack are stacked, and where the decision
|
|
4
|
+
// about who verifies the peer is made. That decision is the reason this package exists, so it is
|
|
5
|
+
// stated once, here, rather than being implied by the call graph:
|
|
6
|
+
//
|
|
7
|
+
// The platform's socket API will happily complete a TLS handshake for us, but the identity it
|
|
8
|
+
// checks is the hostname handed to connect(). Inside a proxy tunnel that hostname is the PROXY,
|
|
9
|
+
// so the platform verifies the wrong party — and it exposes no peer certificate, so we cannot
|
|
10
|
+
// check the right one afterwards either. Every proxied https connection therefore runs the
|
|
11
|
+
// userland handshake, with the trust decision made by this package.
|
|
12
|
+
//
|
|
13
|
+
// The socket factory is injected. Nothing in src/ imports a runtime-specific module, which keeps
|
|
14
|
+
// every layer testable over an in-memory pipe and keeps the package portable.
|
|
15
|
+
|
|
16
|
+
import { ConfigError, codes } from './errors.js';
|
|
17
|
+
import { openTunnel, parseProxy } from './proxy/index.js';
|
|
18
|
+
import { connectTls } from './tls/connect.js';
|
|
19
|
+
import { verifyChain } from './trust/index.js';
|
|
20
|
+
import { DeadlineController } from './util/deadline.js';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_PORT = { 'http:': 80, 'https:': 443 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A request URL reduced to what the transport dials.
|
|
26
|
+
* @typedef {object} TransportTarget
|
|
27
|
+
* @property {URL} url
|
|
28
|
+
* @property {string} hostname IPv6 unbracketed, as the socket API and SOCKS5 want it
|
|
29
|
+
* @property {number} port explicit port, or the scheme default
|
|
30
|
+
* @property {boolean} secure whether the scheme is https:
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Split a request URL into what the transport needs. Throws ConfigError on any scheme other
|
|
35
|
+
* than http: and https:.
|
|
36
|
+
* @param {string | URL} input
|
|
37
|
+
* @returns {TransportTarget}
|
|
38
|
+
*/
|
|
39
|
+
export function targetFromUrl(input) {
|
|
40
|
+
const url = input instanceof URL ? input : new URL(String(input));
|
|
41
|
+
const port = url.port ? Number(url.port) : DEFAULT_PORT[url.protocol];
|
|
42
|
+
if (!port) {
|
|
43
|
+
throw new ConfigError(
|
|
44
|
+
codes.CONFIG_INVALID,
|
|
45
|
+
`unsupported URL scheme "${url.protocol}"; only http: and https: are supported`,
|
|
46
|
+
{ scheme: url.protocol },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
// URL keeps IPv6 hosts bracketed; the socket API and SOCKS5 both want the bare address.
|
|
50
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
51
|
+
return { url, hostname, port, secure: url.protocol === 'https:' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a Response's `tunnelfetch` detail reports about the connection, before the HTTP layer
|
|
56
|
+
* adds the per-response httpVersion and framing.
|
|
57
|
+
* @typedef {object} ConnectionInfo
|
|
58
|
+
* @property {string} url
|
|
59
|
+
* @property {boolean} proxied
|
|
60
|
+
* @property {string | null} proxy the proxy actually used, credentials omitted
|
|
61
|
+
* @property {import('./tls/connect.js').TlsSessionInfo | null} tls null for cleartext
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A live connection carrying application bytes: the duplex, its provenance, and the deadline
|
|
66
|
+
* controller that governs it. This is what the pool stores and what sendAndReceive consumes.
|
|
67
|
+
* @typedef {object} Connection
|
|
68
|
+
* @property {ReadableStream<Uint8Array>} readable
|
|
69
|
+
* @property {WritableStream<Uint8Array>} writable
|
|
70
|
+
* @property {() => Promise<void> | void} close
|
|
71
|
+
* @property {ConnectionInfo} info
|
|
72
|
+
* @property {import('./trust/index.js').ParsedCertificate | null} [peerCertificate] TLS only;
|
|
73
|
+
* null when trust mode 'none' accepted a leaf it could not parse
|
|
74
|
+
* @property {DeadlineController} deadlines
|
|
75
|
+
* @property {boolean} ownsDeadlines whether this call created the controller (and must dispose
|
|
76
|
+
* it) rather than borrowing the caller's
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {object} OpenConnectionOptions
|
|
81
|
+
* @property {string | URL} url
|
|
82
|
+
* @property {import('./proxy/index.js').ConnectFn} connect injected socket factory
|
|
83
|
+
* @property {string | import('./proxy/index.js').ProxyConfig | null} [proxy]
|
|
84
|
+
* @property {import('./trust/index.js').TrustConfig} [trust] the `verify=`-style knob, passed
|
|
85
|
+
* through to the trust layer
|
|
86
|
+
* @property {DeadlineController} [deadlines] borrow the request's controller; omitting it makes
|
|
87
|
+
* this call own (and dispose) a fresh one
|
|
88
|
+
* @property {import('./tls/connect.js').TlsOptions} [tls] handshake options
|
|
89
|
+
* @property {string[]} [alpn] the ALPN protocol list to offer, newest/most-preferred first.
|
|
90
|
+
* Kept separate from `tls` so offering `h2` does not read as a user-supplied TLS option (which
|
|
91
|
+
* would disable native-fetch delegation and enter the pool key). `tls.alpn` still wins if set.
|
|
92
|
+
* @property {{ offer?: import('./tls/connect.js').ResumptionOffer | null,
|
|
93
|
+
* onTicket?: (t: import('./tls/connect.js').CapturedTicket) => void }} [resumption]
|
|
94
|
+
* session-resumption wiring, injected per connection by the Client. Separate from `tls` for
|
|
95
|
+
* the same reason `alpn` is: it must neither disable native-fetch delegation nor enter the
|
|
96
|
+
* pool key — it is not caller configuration, it is state the Client derived FROM the pool key.
|
|
97
|
+
* @property {import('./tls/connect.js').TlsDeps} [deps] injectable randomness/keygen for
|
|
98
|
+
* reproducible handshakes
|
|
99
|
+
* @property {AbortSignal} [signal]
|
|
100
|
+
* @property {{ maxProxyReplyBytes?: number }} [limits] cap on the proxy CONNECT reply head
|
|
101
|
+
* @property {number} [now] epoch ms override for certificate validity
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Open a connection carrying application bytes for `url`. Throws (ProxyError, TlsError,
|
|
106
|
+
* CertificateError, TimeoutError, ConfigError) rather than resolving with a failure value.
|
|
107
|
+
*
|
|
108
|
+
* @param {OpenConnectionOptions} args
|
|
109
|
+
* @returns {Promise<Connection>}
|
|
110
|
+
*/
|
|
111
|
+
export async function openConnection({
|
|
112
|
+
url,
|
|
113
|
+
connect,
|
|
114
|
+
proxy = null,
|
|
115
|
+
trust = { mode: 'system' },
|
|
116
|
+
deadlines,
|
|
117
|
+
tls = {},
|
|
118
|
+
alpn,
|
|
119
|
+
resumption,
|
|
120
|
+
deps = {},
|
|
121
|
+
signal,
|
|
122
|
+
limits = {},
|
|
123
|
+
now,
|
|
124
|
+
}) {
|
|
125
|
+
const target = targetFromUrl(url);
|
|
126
|
+
const proxyConfig = parseProxy(proxy);
|
|
127
|
+
const owns = !deadlines;
|
|
128
|
+
const dl = deadlines ?? new DeadlineController({}, { signal });
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
dl.beginPhase('connect');
|
|
132
|
+
const tunnel = await dl.race(
|
|
133
|
+
openTunnel({
|
|
134
|
+
proxy: proxyConfig,
|
|
135
|
+
target: { hostname: target.hostname, port: target.port },
|
|
136
|
+
connect,
|
|
137
|
+
signal: dl.signal,
|
|
138
|
+
limits,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
dl.endPhase();
|
|
142
|
+
|
|
143
|
+
if (!target.secure) {
|
|
144
|
+
return {
|
|
145
|
+
readable: tunnel.readable,
|
|
146
|
+
writable: tunnel.writable,
|
|
147
|
+
close: () => tunnel.close?.(),
|
|
148
|
+
info: {
|
|
149
|
+
url: target.url.href,
|
|
150
|
+
proxied: tunnel.proxied,
|
|
151
|
+
proxy: proxyConfig ? `${proxyConfig.protocol}://${proxyConfig.hostname}:${proxyConfig.port}` : null,
|
|
152
|
+
tls: null,
|
|
153
|
+
},
|
|
154
|
+
deadlines: dl,
|
|
155
|
+
ownsDeadlines: owns,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dl.beginPhase('handshake');
|
|
160
|
+
let session;
|
|
161
|
+
try {
|
|
162
|
+
session = await dl.race(
|
|
163
|
+
// connectTls offers TLS 1.3 and 1.2 in one ClientHello and follows the server's pick on
|
|
164
|
+
// this same connection. There is no reconnect-and-retry-lower path anywhere: a failure
|
|
165
|
+
// at any version is a failure, because a retry loop is exactly the downgrade lever the
|
|
166
|
+
// RFC 8446 protections exist to deny an attacker. `tls.versions` narrows the offer.
|
|
167
|
+
connectTls({
|
|
168
|
+
transport: tunnel,
|
|
169
|
+
hostname: target.hostname,
|
|
170
|
+
// Bound to this request's trust configuration, so a caller who asked for pinning gets
|
|
171
|
+
// pinning on this connection and nothing else can quietly substitute a laxer policy.
|
|
172
|
+
// The driver's third argument carries the stapled OCSP response when the server sent
|
|
173
|
+
// one; it is judged inside verifyChain under this same trust config.
|
|
174
|
+
verifyPeer: (chain, hostname, details) =>
|
|
175
|
+
verifyChain({ chain, hostname, trust, now, ocspResponse: details?.ocspResponse ?? null }),
|
|
176
|
+
// `alpn` is offered here rather than folded into `tls` so that the ALPN offer never
|
|
177
|
+
// counts as a user TLS option; `tls.alpn` still overrides it when explicitly set.
|
|
178
|
+
// Resumption wiring joins the options the same way, and only when present, so a
|
|
179
|
+
// Client without a ticket produces byte-identical options to before the feature.
|
|
180
|
+
options: assembleTlsOptions(tls, alpn, resumption),
|
|
181
|
+
deps,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
await safeClose(tunnel);
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
dl.endPhase();
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
readable: session.readable,
|
|
192
|
+
writable: session.writable,
|
|
193
|
+
close: async () => {
|
|
194
|
+
try {
|
|
195
|
+
await session.close();
|
|
196
|
+
} finally {
|
|
197
|
+
await safeClose(tunnel);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
info: {
|
|
201
|
+
url: target.url.href,
|
|
202
|
+
proxied: tunnel.proxied,
|
|
203
|
+
proxy: proxyConfig ? `${proxyConfig.protocol}://${proxyConfig.hostname}:${proxyConfig.port}` : null,
|
|
204
|
+
tls: session.info,
|
|
205
|
+
},
|
|
206
|
+
peerCertificate: session.peer,
|
|
207
|
+
deadlines: dl,
|
|
208
|
+
ownsDeadlines: owns,
|
|
209
|
+
};
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (owns) dl.dispose();
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function safeClose(duplex) {
|
|
217
|
+
try {
|
|
218
|
+
await duplex?.close?.();
|
|
219
|
+
} catch {
|
|
220
|
+
/* already gone */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fold the per-connection injections (ALPN offer, resumption wiring) into the caller's TLS
|
|
226
|
+
* options without ever mutating them. The caller's object is what the pool key and the
|
|
227
|
+
* delegation decision were computed from; growing keys on it here would silently change both.
|
|
228
|
+
* @param {import('./tls/connect.js').TlsOptions} tls
|
|
229
|
+
* @param {string[] | undefined} alpn
|
|
230
|
+
* @param {{ offer?: object | null, onTicket?: Function } | undefined} resumption
|
|
231
|
+
* @returns {import('./tls/connect.js').TlsOptions}
|
|
232
|
+
*/
|
|
233
|
+
function assembleTlsOptions(tls, alpn, resumption) {
|
|
234
|
+
const offer = resumption?.offer ?? null;
|
|
235
|
+
const onTicket = resumption?.onTicket ?? null;
|
|
236
|
+
if (!offer && !onTicket && !(alpn && !tls.alpn)) return tls;
|
|
237
|
+
const out = { ...tls };
|
|
238
|
+
if (alpn && !tls.alpn) out.alpn = alpn;
|
|
239
|
+
if (offer) out.psk = offer;
|
|
240
|
+
if (onTicket) out.onSessionTicket = onTicket;
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The delegation decision, with the disqualifying reason spelled out so a caller's error can
|
|
246
|
+
* quote it. Discriminated on `ok` so `reason` is a string exactly when there is one.
|
|
247
|
+
* @typedef {{ ok: true, reason: null } | { ok: false, reason: string }} NativeFetchVerdict
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @typedef {object} NativeFetchQuery
|
|
252
|
+
* @property {import('./proxy/index.js').ProxyConfig | string | null} [proxy]
|
|
253
|
+
* @property {import('./trust/index.js').TrustConfig | null} [trust]
|
|
254
|
+
* @property {import('./tls/connect.js').TlsOptions | null} [tls]
|
|
255
|
+
* @property {boolean} [forceTunnel]
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Can the platform's own fetch() satisfy this request in full?
|
|
260
|
+
*
|
|
261
|
+
* Delegating to the native implementation when it can is strictly better — it is faster, does not
|
|
262
|
+
* burn metered CPU, speaks HTTP/2 and /3, and reaches origins our raw sockets are forbidden from
|
|
263
|
+
* dialling. But delegation must be decided on CAPABILITY, not merely on "is there a proxy":
|
|
264
|
+
* quietly satisfying a request that asked for a pinned certificate by handing it to an
|
|
265
|
+
* implementation using a different trust store would answer a security question the caller did
|
|
266
|
+
* not ask. So anything the native path cannot honour disqualifies it.
|
|
267
|
+
*
|
|
268
|
+
* @param {NativeFetchQuery} query
|
|
269
|
+
* @returns {NativeFetchVerdict}
|
|
270
|
+
*/
|
|
271
|
+
export function nativeFetchCanServe({ proxy, trust, tls, forceTunnel }) {
|
|
272
|
+
if (forceTunnel) return { ok: false, reason: 'forceTunnel was requested' };
|
|
273
|
+
if (proxy) return { ok: false, reason: 'a proxy was configured and fetch() has no proxy option' };
|
|
274
|
+
const mode = trust?.mode ?? 'system';
|
|
275
|
+
if (mode !== 'system') {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
reason: `trust mode "${mode}" cannot be expressed to the platform's fetch(), which exposes ` +
|
|
279
|
+
'no certificate hooks',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (trust?.revocation !== undefined && trust.revocation !== 'staple') {
|
|
283
|
+
// 'require-staple' is a strictness the platform's fetch cannot promise (it exposes no OCSP
|
|
284
|
+
// hooks), and an INVALID value must reach the tunnel path where the trust layer refuses it
|
|
285
|
+
// loudly — delegation would swallow the config error whole. Only the default posture (spelled
|
|
286
|
+
// or not) is delegatable, matching how an unspelled default has always been.
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: `trust.revocation "${trust.revocation}" cannot be honoured by the platform's fetch()`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (tls && Object.keys(tls).length > 0) {
|
|
293
|
+
return { ok: false, reason: 'TLS options were supplied that fetch() cannot honour' };
|
|
294
|
+
}
|
|
295
|
+
return { ok: true, reason: null };
|
|
296
|
+
}
|