tunnelfetch 1.4.1 → 1.6.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 +317 -113
- package/README.zh-CN.md +187 -66
- package/package.json +1 -1
- package/src/client/decode.js +99 -19
- package/src/client.js +37 -5
- package/src/http2/connection.js +123 -30
- package/src/profile/chrome.js +23 -9
- package/src/profile/vendor/brotli-dec.js +156 -0
- package/src/profile/vendor/zstd-dec.js +152 -0
- package/src/profiles.js +18 -1
- package/types/client/decode.d.ts +3 -2
- package/types/client.d.ts +10 -6
- package/types/http2/connection.d.ts +22 -1
- package/types/profile/chrome.d.ts +3 -1
- package/types/profile/vendor/brotli-dec.d.ts +24 -0
- package/types/profile/vendor/zstd-dec.d.ts +26 -0
package/src/http2/connection.js
CHANGED
|
@@ -85,6 +85,25 @@ const FORBIDDEN_H2_HEADERS = new Set([
|
|
|
85
85
|
'upgrade',
|
|
86
86
|
]);
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Charged against the header-block cap for every fragment, on top of that fragment's payload.
|
|
90
|
+
*
|
|
91
|
+
* Without it the cap is bounded on the wrong axis and does not stop the flood it was written for.
|
|
92
|
+
* A CONTINUATION frame may carry a ZERO-length payload: legal, useless, and nine bytes on the wire.
|
|
93
|
+
* It adds nothing to a payload-byte counter while still costing an array slot, a Uint8Array and an
|
|
94
|
+
* ArrayBuffer in `fragments` — so `bytes` stayed at 0 forever, the cap never tripped, END_HEADERS
|
|
95
|
+
* never came, and the array grew until the isolate died. Roughly 10x amplification, which is the
|
|
96
|
+
* shape of CVE-2024-27316 and precisely the attack the cap was added to prevent.
|
|
97
|
+
*
|
|
98
|
+
* Charging per fragment is the same device HPACK uses (`ENTRY_OVERHEAD`, RFC 7541 s4.1): make the
|
|
99
|
+
* per-item cost visible to the accounting so that ONE bound covers both axes. A second counter for
|
|
100
|
+
* frame count would also work and is easier to get subtly wrong — two limits mean two places to
|
|
101
|
+
* forget. The value is a rough stand-in for the real per-fragment heap cost, and it only has to be
|
|
102
|
+
* non-zero to close the hole: at the 262144 default a zero-length flood dies after 1024 frames,
|
|
103
|
+
* while a legitimate 256 KiB block arriving in 16 KiB frames pays 4 KiB of overhead, 1.6%.
|
|
104
|
+
*/
|
|
105
|
+
const HEADER_FRAGMENT_OVERHEAD = 256;
|
|
106
|
+
|
|
88
107
|
/**
|
|
89
108
|
* @typedef {ReadableStream<Uint8Array> & { completed: Promise<boolean>,
|
|
90
109
|
* trailers: Promise<Headers | null> }} BodyStream
|
|
@@ -137,14 +156,34 @@ export class Http2Retryable extends Http2Error {}
|
|
|
137
156
|
*/
|
|
138
157
|
|
|
139
158
|
/**
|
|
140
|
-
* The declared content-length, or null when absent.
|
|
141
|
-
*
|
|
142
|
-
*
|
|
159
|
+
* The declared content-length, or null when absent.
|
|
160
|
+
*
|
|
161
|
+
* @returns {number | null | 'malformed'} `'malformed'` when the field cannot be trusted to mean
|
|
162
|
+
* one length, which the caller must refuse rather than treat as absent.
|
|
163
|
+
*
|
|
164
|
+
* The docblock here used to assert that "a list with conflicting values is refused by the header
|
|
165
|
+
* layer before this is reached". No such refusal existed anywhere, and the consequence was worse
|
|
166
|
+
* than a stale comment: `Headers.get()` joins repeated fields with ", ", so two content-length
|
|
167
|
+
* fields arrive as "10, 20", which fails the digits test and returned null — "no declared length",
|
|
168
|
+
* i.e. the length check silently switched itself OFF for exactly the response most likely to be
|
|
169
|
+
* trying something. A documented precondition that does not hold, whose violation opens the guard.
|
|
170
|
+
*
|
|
171
|
+
* Now the duplicate case is handled here instead of being assumed away. RFC 9110 s8.6 lets a
|
|
172
|
+
* recipient treat repeated identical values as the single value they agree on, and requires
|
|
173
|
+
* treating disagreement as malformed. A single unparseable value is malformed too: it used to be
|
|
174
|
+
* read as "absent", which is the same fail-open by another route.
|
|
143
175
|
*/
|
|
144
176
|
function contentLengthOf(headers) {
|
|
145
177
|
const raw = headers.get?.('content-length') ?? null;
|
|
146
178
|
if (raw === null) return null;
|
|
147
|
-
|
|
179
|
+
const parts = raw.split(',').map((s) => s.trim());
|
|
180
|
+
if (!parts.every((p) => /^\d+$/.test(p))) return 'malformed';
|
|
181
|
+
const distinct = new Set(parts);
|
|
182
|
+
if (distinct.size !== 1) return 'malformed';
|
|
183
|
+
const n = Number(parts[0]);
|
|
184
|
+
// Beyond 2^53 the comparison against a received byte count stops being exact, and no body this
|
|
185
|
+
// runtime can hold comes close, so an absurd declaration is refused rather than approximated.
|
|
186
|
+
return Number.isSafeInteger(n) ? n : 'malformed';
|
|
148
187
|
}
|
|
149
188
|
|
|
150
189
|
export class Http2Connection {
|
|
@@ -317,7 +356,7 @@ export class Http2Connection {
|
|
|
317
356
|
const id = this._nextStreamId;
|
|
318
357
|
this._nextStreamId += 2;
|
|
319
358
|
|
|
320
|
-
const stream = this._createStream(id);
|
|
359
|
+
const stream = this._createStream(id, method);
|
|
321
360
|
if (signal) {
|
|
322
361
|
if (signal.aborted) {
|
|
323
362
|
this._resetStream(stream, H2_ERROR.CANCEL, signal.reason ?? new Http2Error(codes.HTTP2_PROTOCOL, 'aborted'));
|
|
@@ -349,9 +388,14 @@ export class Http2Connection {
|
|
|
349
388
|
return stream.head.promise;
|
|
350
389
|
}
|
|
351
390
|
|
|
352
|
-
_createStream(id) {
|
|
391
|
+
_createStream(id, method = 'GET') {
|
|
353
392
|
const stream = {
|
|
354
393
|
id,
|
|
394
|
+
// The request method, kept because RFC 9110 s8.6 makes content-length mean something
|
|
395
|
+
// different for a HEAD response — the length the body WOULD have had — so the arrived-length
|
|
396
|
+
// check must not run against it. Same for the bodiless 204 and 304, tracked via `status`.
|
|
397
|
+
method,
|
|
398
|
+
status: 0,
|
|
355
399
|
// response head
|
|
356
400
|
head: deferred(),
|
|
357
401
|
responseReceived: false,
|
|
@@ -739,10 +783,12 @@ export class Http2Connection {
|
|
|
739
783
|
this._continuation = {
|
|
740
784
|
streamId,
|
|
741
785
|
fragments: [fragment.slice()],
|
|
742
|
-
bytes: fragment.length,
|
|
786
|
+
bytes: fragment.length + HEADER_FRAGMENT_OVERHEAD,
|
|
787
|
+
frames: 1,
|
|
743
788
|
endStream,
|
|
744
789
|
};
|
|
745
|
-
//
|
|
790
|
+
// Unreachable while `readFrame` is called with DEFAULT_MAX_FRAME_SIZE and the cap is larger,
|
|
791
|
+
// but the cap is configurable and a low one makes the opening frame able to pass it alone.
|
|
746
792
|
if (!this._headerBlockWithinCap()) return;
|
|
747
793
|
}
|
|
748
794
|
}
|
|
@@ -754,16 +800,53 @@ export class Http2Connection {
|
|
|
754
800
|
* stream — which RFC 9113 s4.3 makes a connection error in its own right.
|
|
755
801
|
* @returns {boolean} true when assembly may continue
|
|
756
802
|
*/
|
|
803
|
+
/**
|
|
804
|
+
* The ONE place the receive half of a stream ends. Checks the arrived length against the declared
|
|
805
|
+
* one, resets the stream when they disagree, and only then marks the half ended.
|
|
806
|
+
*
|
|
807
|
+
* Centralised because the previous shape is what let three bypasses through: the check lived
|
|
808
|
+
* inline in the DATA/END_STREAM handler, and the two other routes to the same end state — a
|
|
809
|
+
* response whose HEADERS carried END_STREAM, and a body terminated by trailing HEADERS — simply
|
|
810
|
+
* did not have it. Either one turned `content-length: 1000` plus ten delivered bytes into a
|
|
811
|
+
* complete 200, which is precisely the "short body ending cleanly reached the caller as a
|
|
812
|
+
* complete response" failure the check was added to close. A fourth route added later would have
|
|
813
|
+
* missed it too. Now there is one door.
|
|
814
|
+
*
|
|
815
|
+
* RFC 9110 s8.6 exemptions: for a HEAD response content-length describes the body the request
|
|
816
|
+
* would have produced, and 204/304 carry no body at all, so no arrived length can be compared.
|
|
817
|
+
*
|
|
818
|
+
* @returns {boolean} true when the half ended cleanly; false when the stream was reset
|
|
819
|
+
*/
|
|
820
|
+
_endRecv(stream) {
|
|
821
|
+
const exempt =
|
|
822
|
+
stream.method === 'HEAD' || stream.status === 204 || stream.status === 304;
|
|
823
|
+
if (!exempt && stream.declaredLength !== null && stream.receivedLength !== stream.declaredLength) {
|
|
824
|
+
this._resetStream(
|
|
825
|
+
stream,
|
|
826
|
+
H2_ERROR.PROTOCOL_ERROR,
|
|
827
|
+
new Http2Error(
|
|
828
|
+
codes.HTTP2_PROTOCOL,
|
|
829
|
+
`response body ended at ${stream.receivedLength} bytes against a declared ` +
|
|
830
|
+
`content-length of ${stream.declaredLength}`,
|
|
831
|
+
{ declared: stream.declaredLength, received: stream.receivedLength },
|
|
832
|
+
),
|
|
833
|
+
);
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
stream.recvEnded = true;
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
757
840
|
_headerBlockWithinCap() {
|
|
758
|
-
const { bytes, streamId } = this._continuation;
|
|
841
|
+
const { bytes, frames, streamId } = this._continuation;
|
|
759
842
|
if (bytes <= this._maxHeaderBlockBytes) return true;
|
|
760
843
|
this._continuation = null;
|
|
761
844
|
this._die(
|
|
762
845
|
new Http2Error(
|
|
763
846
|
codes.HTTP2_PROTOCOL,
|
|
764
|
-
`header block on stream ${streamId} reached ${bytes} bytes across
|
|
765
|
-
`CONTINUATION frames, over the ${this._maxHeaderBlockBytes} byte cap`,
|
|
766
|
-
{ streamId, bytes, cap: this._maxHeaderBlockBytes },
|
|
847
|
+
`header block on stream ${streamId} reached ${bytes} charged bytes across ${frames} ` +
|
|
848
|
+
`HEADERS and CONTINUATION frames, over the ${this._maxHeaderBlockBytes} byte cap`,
|
|
849
|
+
{ streamId, bytes, frames, cap: this._maxHeaderBlockBytes },
|
|
767
850
|
),
|
|
768
851
|
);
|
|
769
852
|
return false;
|
|
@@ -771,7 +854,8 @@ export class Http2Connection {
|
|
|
771
854
|
|
|
772
855
|
_onContinuation(flags, payload) {
|
|
773
856
|
this._continuation.fragments.push(payload.slice());
|
|
774
|
-
this._continuation.bytes += payload.length;
|
|
857
|
+
this._continuation.bytes += payload.length + HEADER_FRAGMENT_OVERHEAD;
|
|
858
|
+
this._continuation.frames += 1;
|
|
775
859
|
if (!this._headerBlockWithinCap()) return;
|
|
776
860
|
if (flags & FLAG.END_HEADERS) {
|
|
777
861
|
const { streamId, fragments, endStream } = this._continuation;
|
|
@@ -828,18 +912,37 @@ export class Http2Connection {
|
|
|
828
912
|
return;
|
|
829
913
|
}
|
|
830
914
|
stream.responseReceived = true;
|
|
915
|
+
stream.status = head.status;
|
|
831
916
|
// RFC 9113 s8.1.1: a message with a content-length that disagrees with the DATA delivered is
|
|
832
917
|
// malformed. h1 enforces this through its framing; h2 declares the length in a header and
|
|
833
918
|
// delimits with END_STREAM, so the two can disagree — and a body that silently differs from
|
|
834
919
|
// its declared length is exactly the ambiguity this package refuses everywhere else. Held on
|
|
835
|
-
// the stream and checked
|
|
920
|
+
// the stream and checked at every route out, in _endRecv.
|
|
836
921
|
stream.declaredLength = contentLengthOf(head.headers);
|
|
922
|
+
if (stream.declaredLength === 'malformed') {
|
|
923
|
+
this._resetStream(
|
|
924
|
+
stream,
|
|
925
|
+
H2_ERROR.PROTOCOL_ERROR,
|
|
926
|
+
new Http2Error(
|
|
927
|
+
codes.HTTP2_PROTOCOL,
|
|
928
|
+
'response carries a content-length that does not name one length: conflicting repeated ' +
|
|
929
|
+
'values, or a value that is not a plain integer. RFC 9110 s8.6 makes that malformed, ' +
|
|
930
|
+
'and reading it as "no declared length" would turn the length check off for exactly ' +
|
|
931
|
+
'the response most likely to be probing for that.',
|
|
932
|
+
{ contentLength: head.headers.get?.('content-length') ?? null },
|
|
933
|
+
),
|
|
934
|
+
);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
837
937
|
stream.receivedLength = 0;
|
|
838
938
|
if (endStream) {
|
|
939
|
+
// END_STREAM here declares a zero-length body, so a non-zero content-length is malformed and
|
|
940
|
+
// _endRecv resets the stream. This route used to skip the check entirely, which turned
|
|
941
|
+
// "content-length: 500" plus END_STREAM into an empty body delivered as a complete 200.
|
|
942
|
+
if (!this._endRecv(stream)) return;
|
|
839
943
|
// No body and no trailers: the completion contract is satisfiable now, exactly like the h1
|
|
840
944
|
// "complete at creation" case, so a caller that never reads the (empty) body still lets the
|
|
841
945
|
// deadline dispose.
|
|
842
|
-
stream.recvEnded = true;
|
|
843
946
|
this._settleResolve(stream.completed, true);
|
|
844
947
|
this._settleResolve(stream.trailers, null);
|
|
845
948
|
this._wakePull(stream);
|
|
@@ -872,7 +975,10 @@ export class Http2Connection {
|
|
|
872
975
|
this._resetStream(stream, H2_ERROR.PROTOCOL_ERROR, err);
|
|
873
976
|
return;
|
|
874
977
|
}
|
|
875
|
-
|
|
978
|
+
// Trailers end the body, so the declared length must be satisfied here as well. This route
|
|
979
|
+
// used to skip the check, making a trailing HEADERS the one-frame way to deliver a truncated
|
|
980
|
+
// body as a complete response.
|
|
981
|
+
if (!this._endRecv(stream)) return;
|
|
876
982
|
this._wakePull(stream);
|
|
877
983
|
this._maybeCloseStream(stream);
|
|
878
984
|
}
|
|
@@ -953,20 +1059,7 @@ export class Http2Connection {
|
|
|
953
1059
|
this._wakePull(stream);
|
|
954
1060
|
}
|
|
955
1061
|
if (flags & FLAG.END_STREAM) {
|
|
956
|
-
if (
|
|
957
|
-
this._resetStream(
|
|
958
|
-
stream,
|
|
959
|
-
H2_ERROR.PROTOCOL_ERROR,
|
|
960
|
-
new Http2Error(
|
|
961
|
-
codes.HTTP2_PROTOCOL,
|
|
962
|
-
`response body ended at ${stream.receivedLength} bytes against a declared ` +
|
|
963
|
-
`content-length of ${stream.declaredLength}`,
|
|
964
|
-
{ declared: stream.declaredLength, received: stream.receivedLength },
|
|
965
|
-
),
|
|
966
|
-
);
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
stream.recvEnded = true;
|
|
1062
|
+
if (!this._endRecv(stream)) return;
|
|
970
1063
|
this._wakePull(stream);
|
|
971
1064
|
this._maybeCloseStream(stream);
|
|
972
1065
|
}
|
package/src/profile/chrome.js
CHANGED
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
// import { chrome } from 'tunnelfetch/profile/chrome';
|
|
15
15
|
// new Client({ profile: chrome, connect, proxy });
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// All four requirements are met here, so this profile is usable as it stands. `br` and `zstd` were
|
|
18
|
+
// held back at first on the grounds that there is no single right implementation — measurement
|
|
19
|
+
// dissolved that: a decode-only build of the reference C beats the npm alternative by 1.5x at the
|
|
20
|
+
// interface this package actually uses, and the whole cost is 3 ms once per isolate plus per-byte
|
|
21
|
+
// decoding only when an origin actually serves those codings.
|
|
20
22
|
|
|
21
23
|
import { chrome as declaration } from '../profiles.js';
|
|
22
24
|
import { chacha20poly1305 } from './vendor/chacha20poly1305.js';
|
|
23
25
|
import { mlkem768 } from './vendor/mlkem768.js';
|
|
26
|
+
import { br } from './vendor/brotli-dec.js';
|
|
27
|
+
import { zstd } from './vendor/zstd-dec.js';
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* `profiles.chrome` with the two capabilities this package cannot perform natively already wired
|
|
@@ -33,13 +37,23 @@ import { mlkem768 } from './vendor/mlkem768.js';
|
|
|
33
37
|
*/
|
|
34
38
|
export const chrome = Object.freeze({
|
|
35
39
|
...declaration,
|
|
36
|
-
name: `${declaration.name} (with bundled
|
|
37
|
-
// These satisfy
|
|
38
|
-
//
|
|
39
|
-
// deliberately, because a Chrome that advertises `br` and cannot read it is worse than one that
|
|
40
|
-
// says so up front.
|
|
40
|
+
name: `${declaration.name} (with bundled crypto and codecs)`,
|
|
41
|
+
// These satisfy every entry in `requires`, so the profile constructs. A caller's own
|
|
42
|
+
// `decoders`/`ciphers`/`groups` still win over these — see applyProfile.
|
|
41
43
|
ciphers: Object.freeze({ chacha20: chacha20poly1305 }),
|
|
42
44
|
groups: Object.freeze({ x25519mlkem768: mlkem768 }),
|
|
45
|
+
// Chrome advertises `gzip, deflate, br, zstd`, so the identity is not presentable without these.
|
|
46
|
+
// `decodeBody` now bounds a registered decoder's output by the client's `maxBodyBytes` too (not
|
|
47
|
+
// just the built-in gzip/deflate path), so these honour the caller's cap.
|
|
48
|
+
//
|
|
49
|
+
// Each also self-limits at 256 MiB, and that number is worth being blunt about: a Workers isolate
|
|
50
|
+
// has a 128 MB memory ceiling, so the self-limit is TWICE the ceiling and cannot fire before the
|
|
51
|
+
// isolate is already dead. It is a backstop against a runaway decoder, not against a bomb. On
|
|
52
|
+
// this runtime the only thing that actually stops a decompression bomb is `maxBodyBytes`, which
|
|
53
|
+
// defaults to Infinity — so a caller who fetches arbitrary origins and buffers the result with
|
|
54
|
+
// `.json()`/`.text()`/`.arrayBuffer()` should set it. An earlier version of this comment claimed
|
|
55
|
+
// the self-limit was what kept the 1.4.1 gzip-bomb hole closed for these two codings. It was not.
|
|
56
|
+
decoders: Object.freeze({ br, zstd }),
|
|
43
57
|
});
|
|
44
58
|
|
|
45
|
-
export { chacha20poly1305, mlkem768 };
|
|
59
|
+
export { chacha20poly1305, mlkem768, br, zstd };
|