tunnelfetch 1.4.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnelfetch",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "A fetch-shaped HTTP client that can route through HTTP CONNECT / HTTPS / SOCKS5 proxies on runtimes with only raw TCP, such as Cloudflare Workers. Implements TLS in userland because the runtime cannot verify a tunnelled peer.",
5
5
  "keywords": [
6
6
  "fetch",
@@ -106,7 +106,12 @@ const DECOMPRESS_READ_BYTES = 65536;
106
106
  * consumes many input chunks before producing output, so tying input progress to output
107
107
  * pulls would deadlock.
108
108
  */
109
- function decompressionStage(source, coding) {
109
+ function decompressionStage(source, coding, maxBytes = Infinity) {
110
+ // Bytes this stage has produced. `maxBodyBytes` bounded only the COMPRESSED wire body, so a
111
+ // caller asking for at most 1 MB received 20 MB from a 20 KB gzip bomb — the cap was applied to
112
+ // the wrong side of the decompressor. gzip reaches roughly 1000:1, so the gap was not bounded in
113
+ // any useful sense. Counted here, per stage, so a chain like `br, gzip` cannot exceed it either.
114
+ let produced = 0;
110
115
  const srcReader = source.getReader();
111
116
  /** Rejections here surface through the output stream; pre-observed like chunked.js does. */
112
117
  let pumpDone = null;
@@ -207,6 +212,18 @@ function decompressionStage(source, coding) {
207
212
  return;
208
213
  }
209
214
  if (value.byteLength === 0) continue; // legal, carries nothing; keep reading
215
+ produced += value.byteLength;
216
+ if (produced > maxBytes) {
217
+ // Refused BEFORE the over-long chunk is handed on, so the caller never holds more than
218
+ // it asked for. Fail closed: a truncated body delivered as if complete would be worse.
219
+ throw new HttpError(
220
+ codes.LIMIT_BODY,
221
+ `decoded body exceeded maxBodyBytes: ${produced} bytes of "${coding}" output past a ` +
222
+ `${maxBytes} byte cap. The compressed body was within the cap; the decompressed ` +
223
+ 'one is what a gzip bomb inflates.',
224
+ { coding, produced, maxBytes },
225
+ );
226
+ }
210
227
  c.enqueue(value);
211
228
  return;
212
229
  }
@@ -263,9 +280,11 @@ function firstBytes(chunks, i) {
263
280
  * comma-separated list names codings in the order the SERVER applied them, so decoding
264
281
  * applies them in reverse.
265
282
  * @param {Record<string, BodyDecoder> | null} [decoders] caller-supplied codings
283
+ * @param {number} [maxBytes] cap on DECODED output, per stage. `maxBodyBytes` alone bounded the
284
+ * compressed body, which a gzip bomb walks straight past.
266
285
  * @returns {ReadableStream<Uint8Array>} decoded bytes
267
286
  */
268
- export function decodeBody(stream, contentEncoding, decoders = null) {
287
+ export function decodeBody(stream, contentEncoding, decoders = null, maxBytes = Infinity) {
269
288
  /** Look a coding up among the caller's decoders, case-insensitively as the header is. */
270
289
  const custom = (coding) => {
271
290
  if (!decoders) return null;
@@ -311,6 +330,8 @@ export function decodeBody(stream, contentEncoding, decoders = null) {
311
330
  // point can be reached with the other's assumption.
312
331
  const fn = BUILT_IN.has(coding) ? null : custom(coding);
313
332
  if (fn) {
333
+ // A caller-supplied decoder is not wrapped by the cap: it is their code producing their
334
+ // bytes, and silently truncating its output would be worse than leaving the bound to them.
314
335
  const staged = fn(out);
315
336
  // A decoder that returns something unreadable would surface far downstream as a confusing
316
337
  // stream error; name it here, where the caller can see which coding misbehaved.
@@ -324,7 +345,7 @@ export function decodeBody(stream, contentEncoding, decoders = null) {
324
345
  out = staged;
325
346
  continue;
326
347
  }
327
- out = decompressionStage(out, coding === 'x-gzip' ? 'gzip' : coding);
348
+ out = decompressionStage(out, coding === 'x-gzip' ? 'gzip' : coding, maxBytes);
328
349
  }
329
350
  return out;
330
351
  }
package/src/client.js CHANGED
@@ -72,7 +72,9 @@ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
72
72
  * @property {import('./client/cookies.js').CookieJar} [jar] supply a jar directly, e.g. to share
73
73
  * one across Clients or to persist it.
74
74
  * @property {number} [maxRedirects] default 20.
75
- * @property {number} [maxBodyBytes] enforced from Content-Length before a byte is read.
75
+ * @property {number} [maxBodyBytes] the most body this client will produce. Checked against
76
+ * Content-Length before a byte is read, enforced on the raw stream, and enforced again on the
77
+ * DECODED output — a compressed body within the cap can decompress far past it.
76
78
  * @property {boolean} [decompress] gzip/deflate. Default true.
77
79
  * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
78
80
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
@@ -825,7 +827,7 @@ function decodeResponseBody(body, headers, options) {
825
827
  if (options.decompress === false) return body;
826
828
  const encoding = headers.get('content-encoding');
827
829
  if (!encoding) return body;
828
- return decodeBody(body, encoding, options.decoders ?? null);
830
+ return decodeBody(body, encoding, options.decoders ?? null, options.maxBodyBytes ?? Infinity);
829
831
  }
830
832
 
831
833
  function buildResponse(headInfo, body, framing, conn) {
@@ -23,9 +23,11 @@ export function acceptEncodingFor(decoders: Record<string, BodyDecoder> | null |
23
23
  * comma-separated list names codings in the order the SERVER applied them, so decoding
24
24
  * applies them in reverse.
25
25
  * @param {Record<string, BodyDecoder> | null} [decoders] caller-supplied codings
26
+ * @param {number} [maxBytes] cap on DECODED output, per stage. `maxBodyBytes` alone bounded the
27
+ * compressed body, which a gzip bomb walks straight past.
26
28
  * @returns {ReadableStream<Uint8Array>} decoded bytes
27
29
  */
28
- export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined, decoders?: Record<string, BodyDecoder> | null): ReadableStream<Uint8Array>;
30
+ export function decodeBody(stream: ReadableStream<Uint8Array>, contentEncoding: string | null | undefined, decoders?: Record<string, BodyDecoder> | null, maxBytes?: number): ReadableStream<Uint8Array>;
29
31
  /**
30
32
  * Extract the charset parameter from a Content-Type value, handling quoting and other
31
33
  * parameters: `text/html; boundary=x; charset="ISO-8859-4"` -> 'iso-8859-4'.
package/types/client.d.ts CHANGED
@@ -53,7 +53,9 @@ export function install(options?: ClientOptions): () => void;
53
53
  * @property {import('./client/cookies.js').CookieJar} [jar] supply a jar directly, e.g. to share
54
54
  * one across Clients or to persist it.
55
55
  * @property {number} [maxRedirects] default 20.
56
- * @property {number} [maxBodyBytes] enforced from Content-Length before a byte is read.
56
+ * @property {number} [maxBodyBytes] the most body this client will produce. Checked against
57
+ * Content-Length before a byte is read, enforced on the raw stream, and enforced again on the
58
+ * DECODED output — a compressed body within the cap can decompress far past it.
57
59
  * @property {boolean} [decompress] gzip/deflate. Default true.
58
60
  * @property {Record<string, import('./client/decode.js').BodyDecoder>} [decoders] extra
59
61
  * content-codings this client can read, e.g. `{ br: (s) => ... }`. Registering one is what
@@ -227,7 +229,9 @@ export type ClientOptions = {
227
229
  */
228
230
  maxRedirects?: number | undefined;
229
231
  /**
230
- * enforced from Content-Length before a byte is read.
232
+ * the most body this client will produce. Checked against
233
+ * Content-Length before a byte is read, enforced on the raw stream, and enforced again on the
234
+ * DECODED output — a compressed body within the cap can decompress far past it.
231
235
  */
232
236
  maxBodyBytes?: number | undefined;
233
237
  /**