tunnelfetch 1.0.1 → 1.1.1
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 +146 -10
- package/README.zh-CN.md +86 -5
- package/package.json +2 -2
- package/src/client/decode.js +103 -5
- package/src/client.js +144 -9
- package/src/http2/connection.js +112 -3
- package/src/pool.js +20 -4
- package/types/client/decode.d.ts +32 -2
- package/types/client.d.ts +37 -94
- package/types/http2/connection.d.ts +38 -31
package/src/client.js
CHANGED
|
@@ -14,7 +14,7 @@ import { ConfigError, HttpError, TunnelFetchError, codes } from './errors.js';
|
|
|
14
14
|
import { ByteReader, ByteWriter, UnexpectedEofError, concat, utf8 } from './util/bytes.js';
|
|
15
15
|
import { serializeRequestHead } from './http1/request.js';
|
|
16
16
|
import { bodyFraming, readResponseBody, readResponseHead } from './http1/response.js';
|
|
17
|
-
import {
|
|
17
|
+
import { acceptEncodingFor, decodeBody } from './client/decode.js';
|
|
18
18
|
import { CookieJar } from './client/cookies.js';
|
|
19
19
|
import { DEFAULT_MAX_REDIRECTS, nextRequest, shouldRedirect } from './client/redirect.js';
|
|
20
20
|
import { ConnectionPool, poolKey } from './pool.js';
|
|
@@ -71,8 +71,16 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
|
|
|
71
71
|
* one across Clients or to persist it.
|
|
72
72
|
* @property {number} [maxRedirects] default 20.
|
|
73
73
|
* @property {number} [maxBodyBytes] enforced from Content-Length before a byte is read.
|
|
74
|
-
* @property {boolean} [decompress] gzip/deflate. Default true.
|
|
75
|
-
*
|
|
74
|
+
* @property {boolean} [decompress] gzip/deflate. Default true.
|
|
75
|
+
* @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
|
|
76
|
+
* content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
|
|
77
|
+
* makes advertising it honest, so each name is appended to Accept-Encoding — a client that
|
|
78
|
+
* asked for a coding it cannot decode would turn every such response into garbage. `br` and
|
|
79
|
+
* `zstd` are not built in because the runtime's DecompressionStream has neither and this
|
|
80
|
+
* package takes no dependencies; supply your own and the cost, and the supply chain, are
|
|
81
|
+
* yours and visible. Measured on the edge: WASM brotli decodes at about 2x native gzip, and
|
|
82
|
+
* the wire bytes it saves do not pay that back — see the README. The reason to turn it on is
|
|
83
|
+
* matching a browser's Accept-Encoding, not saving CPU.
|
|
76
84
|
* @property {boolean} [keepAlive] default true.
|
|
77
85
|
* @property {boolean} [http2] offer HTTP/2 via ALPN and speak it when the server selects it.
|
|
78
86
|
* Default true. The goal is ACCESS, not speed — some sites treat HTTP/1.1 as a bot signal — and
|
|
@@ -93,7 +101,7 @@ export class Client {
|
|
|
93
101
|
* @param {ClientOptions} [options]
|
|
94
102
|
*/
|
|
95
103
|
constructor(options = {}) {
|
|
96
|
-
this.options =
|
|
104
|
+
this.options = snapshotOptions(options);
|
|
97
105
|
this.pool = new ConnectionPool(options.pool);
|
|
98
106
|
// HTTP/2 connections are NOT pooled the way h1 is: one connection multiplexes many concurrent
|
|
99
107
|
// streams, so it is not checked out per request. It lives here, keyed exactly like the h1 pool,
|
|
@@ -118,6 +126,12 @@ export class Client {
|
|
|
118
126
|
typeof options.now === 'number' ? { now: () => options.now } : {},
|
|
119
127
|
);
|
|
120
128
|
this._closed = false;
|
|
129
|
+
// Response bodies that have not finished arriving. `fetch` resolves at the response HEAD, so a
|
|
130
|
+
// caller holding a Response may still be streaming over a connection this Client owns — and on
|
|
131
|
+
// HTTP/2 that connection is SHARED and torn down by close(), not checked out of the pool the
|
|
132
|
+
// way an h1 socket is. Anything that decides when a Client is done needs to see these.
|
|
133
|
+
/** @type {Set<Promise<void>>} */
|
|
134
|
+
this._inflight = new Set();
|
|
121
135
|
// Bound so a Client can be handed straight to an SDK expecting a bare function.
|
|
122
136
|
this.fetch = this.fetch.bind(this);
|
|
123
137
|
}
|
|
@@ -134,6 +148,19 @@ export class Client {
|
|
|
134
148
|
return performFetch(this, input, init);
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Resolve once every response body handed out by this Client has finished, one way or another —
|
|
153
|
+
* read to the end, cancelled, or failed. Deliberately NOT folded into close(): close() is the
|
|
154
|
+
* forceful teardown, and a teardown that waits on the streams it is tearing down would hang on
|
|
155
|
+
* any body the caller abandoned. This is for callers that want the graceful order.
|
|
156
|
+
*
|
|
157
|
+
* Looping rather than a single Promise.all because a body settling can start another (a redirect
|
|
158
|
+
* drains its predecessor), and a set sampled once would miss the successor.
|
|
159
|
+
*/
|
|
160
|
+
async idle() {
|
|
161
|
+
while (this._inflight.size) await Promise.all([...this._inflight]);
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
/** Release every pooled socket and shared HTTP/2 connection. A Client that is finished must be
|
|
138
165
|
* closed or sockets leak for the isolate's lifetime. */
|
|
139
166
|
async close() {
|
|
@@ -156,11 +183,27 @@ export class Client {
|
|
|
156
183
|
export function createFetch(options = {}) {
|
|
157
184
|
return async function tunnelFetch(input, init) {
|
|
158
185
|
const client = new Client(options);
|
|
186
|
+
let response;
|
|
159
187
|
try {
|
|
160
|
-
|
|
161
|
-
}
|
|
188
|
+
response = await client.fetch(input, init);
|
|
189
|
+
} catch (err) {
|
|
162
190
|
await client.close();
|
|
191
|
+
throw err;
|
|
163
192
|
}
|
|
193
|
+
// NOT `finally { await client.close() }`. `fetch` resolves when the response HEAD arrives and
|
|
194
|
+
// the body is still on the wire, so closing here tore down the connection out from under the
|
|
195
|
+
// caller — invisibly on HTTP/1.1, where the socket is checked out of the pool and closeAll()
|
|
196
|
+
// could not reach it, but fatally on HTTP/2, where the connection is shared and close() ends
|
|
197
|
+
// every stream on it. `res.text()` then failed with HTTP2_PROTOCOL.
|
|
198
|
+
//
|
|
199
|
+
// Not awaited, or this would deadlock: the body only drains when the caller reads it, and the
|
|
200
|
+
// caller cannot read a Response that has not been returned yet.
|
|
201
|
+
//
|
|
202
|
+
// A caller who neither reads nor cancels the body would leave the Client open — the same leak
|
|
203
|
+
// as forgetting to close one — except that the idle deadline aborts a stalled body, which
|
|
204
|
+
// settles the completion and fires this anyway.
|
|
205
|
+
void client.idle().then(() => client.close().catch(() => {}));
|
|
206
|
+
return response;
|
|
164
207
|
};
|
|
165
208
|
}
|
|
166
209
|
|
|
@@ -422,6 +465,96 @@ function registerHttp2(client, key, conn) {
|
|
|
422
465
|
* @param {unknown} err
|
|
423
466
|
* @returns {boolean}
|
|
424
467
|
*/
|
|
468
|
+
/**
|
|
469
|
+
* Take a private, frozen copy of the security-relevant configuration.
|
|
470
|
+
*
|
|
471
|
+
* `{ ...options }` is a SHALLOW copy, so `client.options.trust` stayed the caller's own object: a
|
|
472
|
+
* caller could flip `revocation` or swap `pins` after construction and the next request would run
|
|
473
|
+
* under the new policy over a pool of connections verified under the old one. The pool key covers
|
|
474
|
+
* every field the verifier reads (see trustFingerprint), but a key computed from a mutable object
|
|
475
|
+
* is only as stable as the object.
|
|
476
|
+
*
|
|
477
|
+
* `proxy` is deliberately NOT frozen: openTunnel treats a frozen proxy as already normalised and
|
|
478
|
+
* skips parseProxy, so freezing a raw object here would bypass its validation entirely. Proxy
|
|
479
|
+
* identity is part of the pool key on its own, so a changed proxy gets a different key regardless.
|
|
480
|
+
*
|
|
481
|
+
* @param {ClientOptions} options
|
|
482
|
+
* @returns {Readonly<ClientOptions>}
|
|
483
|
+
*/
|
|
484
|
+
function snapshotOptions(options) {
|
|
485
|
+
const o = { ...options };
|
|
486
|
+
if (o.trust && typeof o.trust === 'object') o.trust = deepCopyConfig(o.trust);
|
|
487
|
+
if (o.tls && typeof o.tls === 'object') o.tls = deepCopyConfig(o.tls);
|
|
488
|
+
if (o.timeouts && typeof o.timeouts === 'object') o.timeouts = Object.freeze({ ...o.timeouts });
|
|
489
|
+
return Object.freeze(o);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Recursively copy and freeze a configuration value so nothing the caller still holds can change
|
|
494
|
+
* what this Client trusts.
|
|
495
|
+
*
|
|
496
|
+
* A shallow freeze is not enough, and the gap was found in review: `Object.freeze([...anchors])`
|
|
497
|
+
* gives a frozen ARRAY whose elements are still the caller's `Uint8Array`s, and freezing a typed
|
|
498
|
+
* array does not freeze its bytes. Writing into the DER of a trust anchor after the first request
|
|
499
|
+
* therefore changed the certificate material this Client validated against — while the pool key
|
|
500
|
+
* stayed put, because `anchorDigest` memoises per array object. The same shape applies to `tls`,
|
|
501
|
+
* where `groups`, `ciphers` and `clientRandom` are all caller-owned mutable buffers.
|
|
502
|
+
*
|
|
503
|
+
* Only plain objects, arrays and byte arrays are copied. Everything else is passed through by
|
|
504
|
+
* REFERENCE, deliberately: `trust.verify` is a function whose identity is what distinguishes one
|
|
505
|
+
* custom policy from another in the pool key (see `customCounter`), and an AbortSignal or a
|
|
506
|
+
* CookieJar is a live object, not data. Copying either would break them.
|
|
507
|
+
*
|
|
508
|
+
* @template T
|
|
509
|
+
* @param {T} value
|
|
510
|
+
* @param {number} [depth]
|
|
511
|
+
* @returns {T}
|
|
512
|
+
*/
|
|
513
|
+
function deepCopyConfig(value, depth = 0) {
|
|
514
|
+
// Certificate material is not deeply nested; a bound turns a pathological input into an error
|
|
515
|
+
// rather than a stack overflow, and nothing legitimate comes close to it.
|
|
516
|
+
if (depth > 8) {
|
|
517
|
+
throw new ConfigError(
|
|
518
|
+
codes.CONFIG_INVALID,
|
|
519
|
+
'trust or tls configuration nests more than 8 levels deep; refusing to copy it',
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (ArrayBuffer.isView(value)) {
|
|
523
|
+
// Not frozen: V8 refuses to freeze a typed array that has elements. Immutability here comes
|
|
524
|
+
// from the copy being PRIVATE — the caller has no reference to it — rather than from
|
|
525
|
+
// Object.freeze. Copying the underlying buffer slice rather than the view also detaches it
|
|
526
|
+
// from any sibling view over the same memory.
|
|
527
|
+
const Ctor = /** @type {any} */ (value.constructor);
|
|
528
|
+
return new Ctor(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength));
|
|
529
|
+
}
|
|
530
|
+
if (Array.isArray(value)) {
|
|
531
|
+
return Object.freeze(value.map((v) => deepCopyConfig(v, depth + 1)));
|
|
532
|
+
}
|
|
533
|
+
if (value && typeof value === 'object') {
|
|
534
|
+
const proto = Object.getPrototypeOf(value);
|
|
535
|
+
// Only plain objects. A class instance is a live thing with behaviour, and rebuilding it from
|
|
536
|
+
// its own enumerable properties would produce something that merely looks like it.
|
|
537
|
+
if (proto !== Object.prototype && proto !== null) return value;
|
|
538
|
+
const out = {};
|
|
539
|
+
for (const [k, v] of Object.entries(value)) out[k] = deepCopyConfig(v, depth + 1);
|
|
540
|
+
return Object.freeze(out);
|
|
541
|
+
}
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Remember a body until it settles. Never rejects: a failed body is still a finished one, and this
|
|
547
|
+
* set exists to answer "is anything still arriving", not "did it go well".
|
|
548
|
+
*/
|
|
549
|
+
function trackBody(client, completed) {
|
|
550
|
+
const settled = completed.then(
|
|
551
|
+
() => {},
|
|
552
|
+
() => {},
|
|
553
|
+
);
|
|
554
|
+
client._inflight.add(settled);
|
|
555
|
+
settled.then(() => client._inflight.delete(settled));
|
|
556
|
+
}
|
|
557
|
+
|
|
425
558
|
function serverNeverSawIt(err) {
|
|
426
559
|
if (err instanceof UnexpectedEofError) return err.detail?.got === 0;
|
|
427
560
|
return err instanceof TunnelFetchError && err.code === codes.TLS_TRUNCATED && err.detail?.got === 0;
|
|
@@ -493,6 +626,7 @@ async function sendAndReceive(client, conn, current, { key, deadlines, reused })
|
|
|
493
626
|
|
|
494
627
|
// The connection goes back to the pool only when the body reaches the end its framing declared.
|
|
495
628
|
// `completed` resolving false means the caller cancelled and the stream position is unknown.
|
|
629
|
+
trackBody(client, raw.completed);
|
|
496
630
|
raw.completed.then(
|
|
497
631
|
(ok) => {
|
|
498
632
|
deadlines.dispose();
|
|
@@ -556,6 +690,7 @@ async function sendAndReceiveH2(client, h2, current, { deadlines }) {
|
|
|
556
690
|
const raw = head.body;
|
|
557
691
|
// Dispose the per-request deadline once the body is done, however it ends. Unlike h1 there is no
|
|
558
692
|
// pool.release: the connection stays shared and alive for the next stream.
|
|
693
|
+
trackBody(client, raw.completed);
|
|
559
694
|
raw.completed.then(
|
|
560
695
|
() => deadlines.dispose(),
|
|
561
696
|
() => deadlines.dispose(),
|
|
@@ -585,7 +720,7 @@ function buildH2Request(client, current, target) {
|
|
|
585
720
|
|
|
586
721
|
if (!headers.has('accept')) headers.set('accept', '*/*');
|
|
587
722
|
if (!headers.has('accept-encoding') && o.decompress !== false) {
|
|
588
|
-
headers.set('accept-encoding',
|
|
723
|
+
headers.set('accept-encoding', acceptEncodingFor(o.decoders));
|
|
589
724
|
}
|
|
590
725
|
if (client.jar) {
|
|
591
726
|
const cookie = client.jar.headerFor(current.url);
|
|
@@ -624,7 +759,7 @@ function decodeResponseBody(body, headers, options) {
|
|
|
624
759
|
if (options.decompress === false) return body;
|
|
625
760
|
const encoding = headers.get('content-encoding');
|
|
626
761
|
if (!encoding) return body;
|
|
627
|
-
return decodeBody(body, encoding);
|
|
762
|
+
return decodeBody(body, encoding, options.decoders ?? null);
|
|
628
763
|
}
|
|
629
764
|
|
|
630
765
|
function buildResponse(headInfo, body, framing, conn) {
|
|
@@ -669,7 +804,7 @@ function buildHeaders(client, current, target) {
|
|
|
669
804
|
if (!headers.has('accept-encoding') && client.options.decompress !== false) {
|
|
670
805
|
// Never advertise br or zstd: the runtime has no DecompressionStream for either, so the
|
|
671
806
|
// reward for asking would be a body we cannot decode.
|
|
672
|
-
headers.set('accept-encoding',
|
|
807
|
+
headers.set('accept-encoding', acceptEncodingFor(client.options.decoders));
|
|
673
808
|
}
|
|
674
809
|
if (client.jar) {
|
|
675
810
|
const cookie = client.jar.headerFor(current.url);
|
package/src/http2/connection.js
CHANGED
|
@@ -117,10 +117,25 @@ export class Http2Retryable extends Http2Error {}
|
|
|
117
117
|
* @property {number} [maxConcurrentStreams] our advertised SETTINGS_MAX_CONCURRENT_STREAMS.
|
|
118
118
|
* @property {number} [maxHeaderTableSize] our advertised SETTINGS_HEADER_TABLE_SIZE.
|
|
119
119
|
* @property {number} [maxHeaderListSize] self-protection cap on a decoded response header list.
|
|
120
|
+
* @property {number} [maxHeaderBlockBytes] cap on the RAW bytes of one HEADERS+CONTINUATION run,
|
|
121
|
+
* before HPACK decoding. Default 262144, matching the decoded cap. This is the bound that stops
|
|
122
|
+
* a CONTINUATION flood; `maxHeaderListSize` cannot, because it is only reachable once the whole
|
|
123
|
+
* block has been assembled in memory.
|
|
120
124
|
* @property {(err: Error | null) => void} [onClose] called once when the connection dies, so a
|
|
121
125
|
* registry can drop it.
|
|
122
126
|
*/
|
|
123
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The declared content-length, or null when absent. A list with conflicting values is refused by
|
|
130
|
+
* the header layer before this is reached; a malformed single value is treated as absent rather
|
|
131
|
+
* than as zero, because guessing a length is the failure mode being closed here.
|
|
132
|
+
*/
|
|
133
|
+
function contentLengthOf(headers) {
|
|
134
|
+
const raw = headers.get?.('content-length') ?? null;
|
|
135
|
+
if (raw === null) return null;
|
|
136
|
+
return /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
124
139
|
export class Http2Connection {
|
|
125
140
|
/**
|
|
126
141
|
* @param {import('../tls/connect.js').ByteDuplex | { readable: ReadableStream<Uint8Array>,
|
|
@@ -169,7 +184,18 @@ export class Http2Connection {
|
|
|
169
184
|
// Header-block continuation state: while assembling a HEADERS+CONTINUATION run, no other frame
|
|
170
185
|
// may interleave (RFC 9113 s6.10). Non-null means "the next frame must be CONTINUATION on this
|
|
171
186
|
// stream id".
|
|
172
|
-
this._continuation = null; // { streamId, fragments: Uint8Array[], endStream, kind }
|
|
187
|
+
this._continuation = null; // { streamId, fragments: Uint8Array[], bytes, endStream, kind }
|
|
188
|
+
// A header block arrives as a HEADERS frame plus any number of CONTINUATION frames, and it is
|
|
189
|
+
// only decodable once the last one lands — so the fragments must be held. `maxHeaderListSize`
|
|
190
|
+
// bounds the DECODED list and is checked in _completeHeaderBlock, which is to say after the
|
|
191
|
+
// whole block is already in memory: a peer that simply never sets END_HEADERS never reaches
|
|
192
|
+
// it. That is the CONTINUATION flood (the class behind CVE-2024-27316 and its siblings), and
|
|
193
|
+
// it is bounded here instead, on the raw bytes, as they arrive.
|
|
194
|
+
//
|
|
195
|
+
// 262144 by default, the same figure as the decoded cap. HPACK does not expand — an indexed
|
|
196
|
+
// reference makes a small input decode LARGER, never the reverse — so a block whose raw size
|
|
197
|
+
// exceeds the decoded cap could not have produced an acceptable header list anyway.
|
|
198
|
+
this._maxHeaderBlockBytes = opts.maxHeaderBlockBytes ?? 262144;
|
|
173
199
|
this._expectFirstSettings = true;
|
|
174
200
|
|
|
175
201
|
this._fatal = null; // set once; rejects every stream and every future request
|
|
@@ -306,6 +332,9 @@ export class Http2Connection {
|
|
|
306
332
|
/** @type {Uint8Array[]} */
|
|
307
333
|
recvQueue: [],
|
|
308
334
|
recvEnded: false,
|
|
335
|
+
/** @type {number | null} declared content-length, null when absent */
|
|
336
|
+
declaredLength: null,
|
|
337
|
+
receivedLength: 0,
|
|
309
338
|
/** @type {Error | null} */
|
|
310
339
|
bodyError: null,
|
|
311
340
|
/** @type {(() => void) | null} */
|
|
@@ -368,7 +397,13 @@ export class Http2Connection {
|
|
|
368
397
|
this._connSendWindow -= n;
|
|
369
398
|
const end = offset >= body.byteLength;
|
|
370
399
|
await this._write(dataFrame(streamId, slice, end));
|
|
371
|
-
if (end)
|
|
400
|
+
if (end) {
|
|
401
|
+
stream.localEnded = true;
|
|
402
|
+
// The remote half may have ended long ago — a server is free to answer before the upload
|
|
403
|
+
// finishes — in which case nothing else will ever re-check, and the stream stays in the
|
|
404
|
+
// map for the life of the connection. Every other place that ends a half calls this.
|
|
405
|
+
this._maybeCloseStream(stream);
|
|
406
|
+
}
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
|
|
@@ -673,12 +708,43 @@ export class Http2Connection {
|
|
|
673
708
|
if (flags & FLAG.END_HEADERS) {
|
|
674
709
|
this._completeHeaderBlock(streamId, fragment, endStream);
|
|
675
710
|
} else {
|
|
676
|
-
this._continuation = {
|
|
711
|
+
this._continuation = {
|
|
712
|
+
streamId,
|
|
713
|
+
fragments: [fragment.slice()],
|
|
714
|
+
bytes: fragment.length,
|
|
715
|
+
endStream,
|
|
716
|
+
};
|
|
717
|
+
// The opening frame alone can exceed the cap when SETTINGS_MAX_FRAME_SIZE is large.
|
|
718
|
+
if (!this._headerBlockWithinCap()) return;
|
|
677
719
|
}
|
|
678
720
|
}
|
|
679
721
|
|
|
722
|
+
/**
|
|
723
|
+
* Enforce the raw header-block cap, killing the connection when it is passed.
|
|
724
|
+
* Connection-level rather than stream-level on purpose: the fragments are HPACK input, and
|
|
725
|
+
* abandoning a partial block would leave the shared decoder desynchronised for every other
|
|
726
|
+
* stream — which RFC 9113 s4.3 makes a connection error in its own right.
|
|
727
|
+
* @returns {boolean} true when assembly may continue
|
|
728
|
+
*/
|
|
729
|
+
_headerBlockWithinCap() {
|
|
730
|
+
const { bytes, streamId } = this._continuation;
|
|
731
|
+
if (bytes <= this._maxHeaderBlockBytes) return true;
|
|
732
|
+
this._continuation = null;
|
|
733
|
+
this._die(
|
|
734
|
+
new Http2Error(
|
|
735
|
+
codes.HTTP2_PROTOCOL,
|
|
736
|
+
`header block on stream ${streamId} reached ${bytes} bytes across HEADERS and ` +
|
|
737
|
+
`CONTINUATION frames, over the ${this._maxHeaderBlockBytes} byte cap`,
|
|
738
|
+
{ streamId, bytes, cap: this._maxHeaderBlockBytes },
|
|
739
|
+
),
|
|
740
|
+
);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
680
744
|
_onContinuation(flags, payload) {
|
|
681
745
|
this._continuation.fragments.push(payload.slice());
|
|
746
|
+
this._continuation.bytes += payload.length;
|
|
747
|
+
if (!this._headerBlockWithinCap()) return;
|
|
682
748
|
if (flags & FLAG.END_HEADERS) {
|
|
683
749
|
const { streamId, fragments, endStream } = this._continuation;
|
|
684
750
|
this._continuation = null;
|
|
@@ -734,6 +800,13 @@ export class Http2Connection {
|
|
|
734
800
|
return;
|
|
735
801
|
}
|
|
736
802
|
stream.responseReceived = true;
|
|
803
|
+
// RFC 9113 s8.1.1: a message with a content-length that disagrees with the DATA delivered is
|
|
804
|
+
// malformed. h1 enforces this through its framing; h2 declares the length in a header and
|
|
805
|
+
// delimits with END_STREAM, so the two can disagree — and a body that silently differs from
|
|
806
|
+
// its declared length is exactly the ambiguity this package refuses everywhere else. Held on
|
|
807
|
+
// the stream and checked as DATA arrives and again at END_STREAM.
|
|
808
|
+
stream.declaredLength = contentLengthOf(head.headers);
|
|
809
|
+
stream.receivedLength = 0;
|
|
737
810
|
if (endStream) {
|
|
738
811
|
// No body and no trailers: the completion contract is satisfiable now, exactly like the h1
|
|
739
812
|
// "complete at creation" case, so a caller that never reads the (empty) body still lets the
|
|
@@ -777,6 +850,13 @@ export class Http2Connection {
|
|
|
777
850
|
}
|
|
778
851
|
|
|
779
852
|
_onData(flags, streamId, payload) {
|
|
853
|
+
if (streamId === 0) {
|
|
854
|
+
// RFC 9113 s6.1: DATA is always associated with a stream, and a zero id MUST be a connection
|
|
855
|
+
// error. Absorbing it silently was letting a peer push bytes with no stream to charge them
|
|
856
|
+
// to, which is the shape of a smuggling primitive as much as a resource one.
|
|
857
|
+
this._die(new Http2Error(codes.HTTP2_PROTOCOL, 'DATA frame on stream 0'));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
780
860
|
const stream = this._streams.get(streamId);
|
|
781
861
|
// Flow control is accounted at the connection level for EVERY DATA frame, even one for a
|
|
782
862
|
// stream we have already closed — the peer spent connection window to send it, and not
|
|
@@ -824,11 +904,40 @@ export class Http2Connection {
|
|
|
824
904
|
if (overhead > 0) this._replenishConn(overhead);
|
|
825
905
|
// The stream window was debited by flowLen; credit the overhead back on the stream too.
|
|
826
906
|
if (overhead > 0) this._replenish(stream, overhead);
|
|
907
|
+
stream.receivedLength += data.byteLength;
|
|
908
|
+
// Caught on the way past rather than only at END_STREAM, so an over-long body is refused
|
|
909
|
+
// before the excess is queued for the caller.
|
|
910
|
+
if (stream.declaredLength !== null && stream.receivedLength > stream.declaredLength) {
|
|
911
|
+
this._resetStream(
|
|
912
|
+
stream,
|
|
913
|
+
H2_ERROR.PROTOCOL_ERROR,
|
|
914
|
+
new Http2Error(
|
|
915
|
+
codes.HTTP2_PROTOCOL,
|
|
916
|
+
`response body is longer than its content-length: ${stream.receivedLength} bytes so ` +
|
|
917
|
+
`far against a declared ${stream.declaredLength}`,
|
|
918
|
+
{ declared: stream.declaredLength, received: stream.receivedLength },
|
|
919
|
+
),
|
|
920
|
+
);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
827
923
|
if (data.byteLength > 0) {
|
|
828
924
|
stream.recvQueue.push(data.slice());
|
|
829
925
|
this._wakePull(stream);
|
|
830
926
|
}
|
|
831
927
|
if (flags & FLAG.END_STREAM) {
|
|
928
|
+
if (stream.declaredLength !== null && stream.receivedLength !== stream.declaredLength) {
|
|
929
|
+
this._resetStream(
|
|
930
|
+
stream,
|
|
931
|
+
H2_ERROR.PROTOCOL_ERROR,
|
|
932
|
+
new Http2Error(
|
|
933
|
+
codes.HTTP2_PROTOCOL,
|
|
934
|
+
`response body ended at ${stream.receivedLength} bytes against a declared ` +
|
|
935
|
+
`content-length of ${stream.declaredLength}`,
|
|
936
|
+
{ declared: stream.declaredLength, received: stream.receivedLength },
|
|
937
|
+
),
|
|
938
|
+
);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
832
941
|
stream.recvEnded = true;
|
|
833
942
|
this._wakePull(stream);
|
|
834
943
|
this._maybeCloseStream(stream);
|
package/src/pool.js
CHANGED
|
@@ -52,15 +52,31 @@ export function poolKey({ scheme, hostname, port, proxy, trust, tls }) {
|
|
|
52
52
|
|
|
53
53
|
function trustFingerprint(trust) {
|
|
54
54
|
const mode = trust?.mode ?? 'system';
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// The fingerprint must cover exactly the fields the VERIFIER reads for this mode — no fewer, or
|
|
56
|
+
// a connection validated under one policy serves a request under another; no more, or the pool
|
|
57
|
+
// is split along axes that make no difference. src/trust/index.js states the set per mode by
|
|
58
|
+
// rejecting every other key outright (`forbidKeys`), so this mirrors those lists.
|
|
59
|
+
const rev = `rev=${trust?.revocation ?? 'staple'}`;
|
|
60
|
+
const pins = `pins=${[...(trust?.pins ?? [])].sort().join(',')}`;
|
|
61
|
+
// Anchors matter even when they are absent: omitting them means the system store, and a policy
|
|
62
|
+
// pinned against custom anchors is not the policy pinned against the system store.
|
|
63
|
+
const anchors =
|
|
64
|
+
trust?.anchors === undefined
|
|
65
|
+
? 'anchors=system'
|
|
66
|
+
: `anchors=${trust.anchors.length}:${anchorDigest(trust.anchors)}`;
|
|
67
|
+
|
|
68
|
+
// 'none' forbids anchors/verify/revocation, so pins are the only thing that can vary — and they
|
|
69
|
+
// DO vary: pin-only trust still enforces them.
|
|
70
|
+
if (mode === 'none') return `none|${pins}`;
|
|
57
71
|
if (mode === 'custom') {
|
|
58
72
|
// Two different callbacks are two different policies and we cannot compare functions, so a
|
|
59
73
|
// custom policy never shares a connection. Correct, and cheap: custom trust is rare.
|
|
74
|
+
// ('custom' forbids anchors/pins/revocation — the callback owns the whole decision.)
|
|
60
75
|
return `custom:${customCounter(trust.verify)}`;
|
|
61
76
|
}
|
|
62
|
-
if (mode === '
|
|
63
|
-
if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}`;
|
|
77
|
+
if (mode === 'system') return `system|${rev}`;
|
|
78
|
+
if (mode === 'anchors') return `anchors:${(trust.anchors ?? []).length}:${anchorDigest(trust.anchors)}|${rev}`;
|
|
79
|
+
if (mode === 'pinned') return `pinned|${pins}|${anchors}|${rev}`;
|
|
64
80
|
return `unknown:${mode}`;
|
|
65
81
|
}
|
|
66
82
|
|
package/types/client/decode.d.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Accept-Encoding to send given the caller's extra decoders.
|
|
3
|
+
*
|
|
4
|
+
* Registering a decoder is what makes advertising its coding honest — the two must move together
|
|
5
|
+
* or the client asks for bytes it cannot read. Order is registration order after the built-ins,
|
|
6
|
+
* so a caller matching a browser can produce exactly `gzip, deflate, br, zstd`.
|
|
7
|
+
*
|
|
8
|
+
* @param {Record<string, BodyDecoder> | null | undefined} decoders
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function acceptEncodingFor(decoders: Record<string, BodyDecoder> | null | undefined): string;
|
|
1
12
|
/**
|
|
2
13
|
* Undo the response's Content-Encoding.
|
|
3
14
|
*
|
|
15
|
+
/**
|
|
16
|
+
* A caller-supplied content decoder: raw coded bytes in, decoded bytes out. Streaming, so a body
|
|
17
|
+
* is never fully buffered on this client's behalf.
|
|
18
|
+
* @typedef {(stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>} BodyDecoder
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
4
21
|
* @param {ReadableStream<Uint8Array>} stream the raw body
|
|
5
22
|
* @param {string|null|undefined} contentEncoding the Content-Encoding header value; a
|
|
6
23
|
* comma-separated list names codings in the order the SERVER applied them, so decoding
|
|
7
24
|
* applies them in reverse.
|
|
25
|
+
* @param {Record<string, BodyDecoder> | null} [decoders] caller-supplied codings
|
|
8
26
|
* @returns {ReadableStream<Uint8Array>} decoded bytes
|
|
9
27
|
*/
|
|
10
|
-
export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined): ReadableStream<Uint8Array>;
|
|
28
|
+
export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined, decoders?: Record<string, BodyDecoder> | null): ReadableStream<Uint8Array>;
|
|
11
29
|
/**
|
|
12
30
|
* Extract the charset parameter from a Content-Type value, handling quoting and other
|
|
13
31
|
* parameters: `text/html; boundary=x; charset="ISO-8859-4"` -> 'iso-8859-4'.
|
|
@@ -46,9 +64,21 @@ export function charsetFor(contentType: string | null | undefined, bodyPrefix?:
|
|
|
46
64
|
*/
|
|
47
65
|
export function decodeText(bytes: Uint8Array, charset?: string): string;
|
|
48
66
|
/**
|
|
49
|
-
*
|
|
67
|
+
* What the request layer advertises with no extra decoders registered. The target runtime's
|
|
50
68
|
* DecompressionStream supports ONLY gzip / deflate / deflate-raw (verified empirically);
|
|
51
69
|
* advertising `br` or `zstd` would invite bytes we can never decode, turning every response
|
|
52
70
|
* from a brotli-preferring CDN into garbage. Keep this list and decodeBody in lockstep.
|
|
71
|
+
*
|
|
72
|
+
* It is also exactly what curl sends, which matters because this client presents curl's TLS and
|
|
73
|
+
* HTTP/2 fingerprints by default: a browser-shaped handshake paired with a curl-shaped
|
|
74
|
+
* Accept-Encoding is a mismatch a bot detector can read straight off the wire.
|
|
53
75
|
*/
|
|
54
76
|
export const ACCEPT_ENCODING: "gzip, deflate";
|
|
77
|
+
/**
|
|
78
|
+
* Undo the response's Content-Encoding.
|
|
79
|
+
*
|
|
80
|
+
* /**
|
|
81
|
+
* A caller-supplied content decoder: raw coded bytes in, decoded bytes out. Streaming, so a body
|
|
82
|
+
* is never fully buffered on this client's behalf.
|
|
83
|
+
*/
|
|
84
|
+
export type BodyDecoder = (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>;
|