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.
@@ -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. A list with conflicting values is refused by
141
- * the header layer before this is reached; a malformed single value is treated as absent rather
142
- * than as zero, because guessing a length is the failure mode being closed here.
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
- return /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : null;
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
- // The opening frame alone can exceed the cap when SETTINGS_MAX_FRAME_SIZE is large.
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 HEADERS and ` +
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 as DATA arrives and again at END_STREAM.
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
- stream.recvEnded = true;
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 (stream.declaredLength !== null && stream.receivedLength !== stream.declaredLength) {
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
  }
@@ -14,13 +14,17 @@
14
14
  // import { chrome } from 'tunnelfetch/profile/chrome';
15
15
  // new Client({ profile: chrome, connect, proxy });
16
16
  //
17
- // Still not supplied here: `br` and `zstd` decoders. Those are not cryptography and there is no
18
- // single right implementation bring your own through `decoders` (see the README). The profile
19
- // will keep refusing until you do, which is the point.
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 ML-KEM and ChaCha20)`,
37
- // These satisfy two of the four entries in `requires`. `decoder:br` and `decoder:zstd` remain the
38
- // caller's, so constructing a Client with this profile still fails until they are supplied
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 };