tunnelfetch 1.1.0 → 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 +9 -7
- package/README.zh-CN.md +7 -6
- package/package.json +1 -1
- package/src/client.js +55 -7
- package/src/http2/connection.js +112 -3
- package/types/http2/connection.d.ts +38 -31
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Zero dependencies. ESM. No build step. No `node:` imports anywhere in `src/`.
|
|
|
14
14
|
|
|
15
15
|
> **Maturity.** This is a new implementation, not a battle-tested one. It implements TLS 1.2/1.3 and
|
|
16
16
|
> certificate validation in userland — a category where good tests are necessary and not sufficient.
|
|
17
|
-
> It has
|
|
17
|
+
> It has over 1100 hermetic tests, RFC vectors, byte-by-byte fragmentation, live edge interop, seeded
|
|
18
18
|
> fuzzing of every peer-facing parser, and 95% line coverage. It has **not** had an external
|
|
19
19
|
> security audit. Treat it as a high-quality implementation worth trying, not as something proven
|
|
20
20
|
> in production. Please report anything you find — see [SECURITY.md](SECURITY.md).
|
|
@@ -343,7 +343,8 @@ res.tunnelfetch.httpVersion; // '2' if the server chose h2, '1.1' otherwise
|
|
|
343
343
|
| `cookies` | `false` | Enable a per-Client cookie jar. |
|
|
344
344
|
| `maxRedirects` | `20` | |
|
|
345
345
|
| `maxBodyBytes` | `Infinity` | Enforced from `Content-Length` before a byte is read. |
|
|
346
|
-
| `decompress` | `true` |
|
|
346
|
+
| `decompress` | `true` | Decode `Content-Encoding` at all. gzip and deflate are built in. |
|
|
347
|
+
| `decoders` | `{}` | Extra codings, e.g. `{ br: fn }`. Each is added to `Accept-Encoding`. See [`br`, `zstd`](#br-zstd-and-other-codings). |
|
|
347
348
|
| `keepAlive` | `true` | |
|
|
348
349
|
| `http2` | `true` | Offer `h2` in ALPN and speak it if the server selects it. See [HTTP/2](#http2--access-not-speed). |
|
|
349
350
|
| `forceTunnel` | `false` | Never delegate to the platform's `fetch`. |
|
|
@@ -675,11 +676,11 @@ WHATWG Streams including BYOB readers, `TextEncoder`/`TextDecoder`, `Decompressi
|
|
|
675
676
|
|
|
676
677
|
| Runtime | Offline suite | Notes |
|
|
677
678
|
|---|---|---|
|
|
678
|
-
| Node 22, 24 | **
|
|
679
|
+
| Node 22, 24 | **all pass** | what CI gates on |
|
|
679
680
|
| Node 20 | not supported | `TextDecoder` treats `iso-8859-1` as true ISO-8859-1 instead of aliasing it to windows-1252 as WHATWG requires, so bodies in that charset decode differently. Left maintenance April 2026 |
|
|
680
681
|
| workerd | live edge suite passes | the target runtime; exercised end to end by the scheduled edge job rather than by the offline suite |
|
|
681
|
-
| Deno 2.9 |
|
|
682
|
-
| Bun 1.3 |
|
|
682
|
+
| Deno 2.9 | 2 failures | both failures are in the TLS 1.2 test server's secp521r1 path, not in the package; WebCrypto ECDSA and ECDH on P-521 both work standalone under Deno. Unresolved, so support is not claimed |
|
|
683
|
+
| Bun 1.3 | 3 failures | a module-resolution difference in one repo-hygiene test, one timing-sensitive deadline test, and the same TLS 1.2 suite test. Unresolved, so support is not claimed |
|
|
683
684
|
|
|
684
685
|
Node 22 and workerd are the supported pair. Deno and Bun very nearly work and are not tested in CI
|
|
685
686
|
— running the suite there is a good first contribution.
|
|
@@ -710,8 +711,9 @@ against independently written test servers — the 1.2 server is built on `node:
|
|
|
710
711
|
on this package's own primitives, so a client bug cannot be cancelled out by the same bug on the
|
|
711
712
|
server side.
|
|
712
713
|
|
|
713
|
-
Every parser that consumes bytes a peer controls —
|
|
714
|
-
HTTP/2 frames, HPACK — is fuzzed against one
|
|
714
|
+
Every parser that consumes bytes a peer controls — the TLS record layer and handshake messages,
|
|
715
|
+
X.509, OCSP, HTTP/1.1 heads, chunked bodies, HTTP/2 frames, HPACK — is fuzzed against one
|
|
716
|
+
property: **any input either parses or throws a
|
|
715
717
|
`TunnelFetchError`.** An untyped throw, a `TypeError` from a missing null check or a `RangeError`
|
|
716
718
|
from a bad offset, is a finding: it means a check is missing and every caller relying on the typed
|
|
717
719
|
contract to fail closed will not catch it.
|
package/README.zh-CN.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
零依赖。ESM。无构建步骤。`src/` 中没有任何 `node:` 导入。
|
|
13
13
|
|
|
14
|
-
> **成熟度。** 这是一个新实现,不是久经沙场的实现。它在用户态实现了 TLS 1.2/1.3
|
|
14
|
+
> **成熟度。** 这是一个新实现,不是久经沙场的实现。它在用户态实现了 TLS 1.2/1.3 与证书校验——在这个领域,好的测试是必要条件,但不是充分条件。它有一千一百多个离线测试、RFC 向量、逐字节分片、真实边缘互操作测试、对每一个面向对端的解析器做种子化 fuzzing,行覆盖率 95%;但它**没有**经过外部安全审计。请把它当作一个值得一试的高质量实现,而不是一个已被生产验证的东西。发现任何问题都欢迎报告——见 [SECURITY.md](SECURITY.md)。
|
|
15
15
|
|
|
16
16
|
```js
|
|
17
17
|
import { Client } from 'tunnelfetch';
|
|
@@ -278,7 +278,8 @@ res.tunnelfetch.httpVersion; // 服务器选了 h2 就是 '2',否则是 '1.1'
|
|
|
278
278
|
| `cookies` | `false` | 启用该 Client 专属的 cookie jar。 |
|
|
279
279
|
| `maxRedirects` | `20` | |
|
|
280
280
|
| `maxBodyBytes` | `Infinity` | 在读入任何字节之前就按 `Content-Length` 强制执行。 |
|
|
281
|
-
| `decompress` | `true` | gzip
|
|
281
|
+
| `decompress` | `true` | 是否解码 `Content-Encoding`。gzip 与 deflate 内置。 |
|
|
282
|
+
| `decoders` | `{}` | 额外的编码,如 `{ br: fn }`;每一个都会被加进 `Accept-Encoding`。见 [`br`、`zstd`](#brzstd-与其它编码)。 |
|
|
282
283
|
| `keepAlive` | `true` | |
|
|
283
284
|
| `http2` | `true` | 在 ALPN 中报出 `h2`,服务器选中即使用。见 [HTTP/2](#http2--要的是访问不是速度)。 |
|
|
284
285
|
| `forceTunnel` | `false` | 永不委托给平台的 `fetch`。 |
|
|
@@ -501,11 +502,11 @@ WebCrypto (X25519、ECDH P-256/384/521、ECDSA、RSA-PSS、RSASSA-PKCS1、HKDF
|
|
|
501
502
|
|
|
502
503
|
| 运行时 | 离线测试 | 说明 |
|
|
503
504
|
|---|---|---|
|
|
504
|
-
| Node 22、24 |
|
|
505
|
+
| Node 22、24 | **全部通过** | CI 把关的组合 |
|
|
505
506
|
| Node 20 | 不支持 | `TextDecoder` 把 `iso-8859-1` 当作真正的 ISO-8859-1,而没有按 WHATWG 要求别名到 windows-1252,该字符集的响应体解码结果不同。已于 2026 年 4 月结束维护 |
|
|
506
507
|
| workerd | live 边缘测试全过 | 目标运行时;由定时边缘任务端到端验证,不在离线套件里 |
|
|
507
|
-
| Deno 2.9 |
|
|
508
|
-
| Bun 1.3 |
|
|
508
|
+
| Deno 2.9 | 2 个失败 | 两个失败都落在 TLS 1.2 测试服务器的 secp521r1 路径上,不在包内;Deno 的 WebCrypto ECDSA 与 ECDH 在 P-521 上单独测试都正常。原因未定位,因此不宣称支持 |
|
|
509
|
+
| Bun 1.3 | 3 个失败 | 一个 repo-hygiene 测试的模块解析差异、一个对时序敏感的 deadline 测试,以及同一个 TLS 1.2 套件测试。原因未定位,因此不宣称支持 |
|
|
509
510
|
|
|
510
511
|
受支持的组合是 Node 22 与 workerd。Deno 和 Bun 已经非常接近可用,但没有进 CI——把这套测试在那两个运行时上跑通,是一个很好的首次贡献。
|
|
511
512
|
|
|
@@ -524,7 +525,7 @@ npm run test:live # explicit; needs TUNNELFETCH_PROXY in the environment
|
|
|
524
525
|
|
|
525
526
|
TLS 密钥调度与记录层逐字节钉死在 **RFC 8448** "Example Handshake Traces for TLS 1.3" 上:AEAD 复现出 RFC 里一模一样的密文记录,记录层把 RFC 的完整线上字节在两个方向上原样重放。两个 TLS 驱动都对着独立编写的测试服务器测试——1.2 的服务器建在 `node:crypto` 上,而不是本包自己的原语上,这样客户端的 bug 就不可能被服务器端的同一个 bug 抵消掉。
|
|
526
527
|
|
|
527
|
-
每一个消费对端字节的解析器——X.509、OCSP、HTTP/1.1 响应头、分块体、HTTP/2 帧、HPACK——都会被 fuzz,断言的属性只有一条:**任意输入,要么解析成功,要么抛出 `TunnelFetchError`。** 抛出未类型化的错误(少了空值检查的 `TypeError`、越界的 `RangeError`)就是一个发现:它意味着某处检查缺失,而所有依赖类型化契约来 fail closed 的调用方都接不住它。
|
|
528
|
+
每一个消费对端字节的解析器——TLS 记录层与握手消息、X.509、OCSP、HTTP/1.1 响应头、分块体、HTTP/2 帧、HPACK——都会被 fuzz,断言的属性只有一条:**任意输入,要么解析成功,要么抛出 `TunnelFetchError`。** 抛出未类型化的错误(少了空值检查的 `TypeError`、越界的 `RangeError`)就是一个发现:它意味着某处检查缺失,而所有依赖类型化契约来 fail closed 的调用方都接不住它。
|
|
528
529
|
|
|
529
530
|
fuzzer 自带种子、零依赖,失败时会打印种子、迭代序号和 base64 的输入——可以精确复现,而无法复现失败的 fuzzer 算不上 fuzzer。目标从 `test/fuzz/targets/` 自动发现,新增一个就是放一个文件进去。这套测试还会 fuzz **它自己**:两个合成目标分别证明引擎能报出未类型化的抛出、且不会误报类型化的抛出——否则一次全绿的 fuzz 只能证明 fuzzer 从来没睁眼。
|
|
530
531
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tunnelfetch",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.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",
|
package/src/client.js
CHANGED
|
@@ -483,17 +483,65 @@ function registerHttp2(client, key, conn) {
|
|
|
483
483
|
*/
|
|
484
484
|
function snapshotOptions(options) {
|
|
485
485
|
const o = { ...options };
|
|
486
|
-
if (o.trust && typeof o.trust === 'object')
|
|
487
|
-
|
|
488
|
-
if (Array.isArray(t.pins)) t.pins = Object.freeze([...t.pins]);
|
|
489
|
-
if (Array.isArray(t.anchors)) t.anchors = Object.freeze([...t.anchors]);
|
|
490
|
-
o.trust = Object.freeze(t);
|
|
491
|
-
}
|
|
492
|
-
if (o.tls && typeof o.tls === 'object') o.tls = Object.freeze({ ...o.tls });
|
|
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);
|
|
493
488
|
if (o.timeouts && typeof o.timeouts === 'object') o.timeouts = Object.freeze({ ...o.timeouts });
|
|
494
489
|
return Object.freeze(o);
|
|
495
490
|
}
|
|
496
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
|
+
|
|
497
545
|
/**
|
|
498
546
|
* Remember a body until it settles. Never rejects: a failed body is still a finished one, and this
|
|
499
547
|
* set exists to answer "is anything still arriving", not "did it go well".
|
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);
|
|
@@ -34,19 +34,6 @@ export function buildRequestFields({ method, scheme, authority, path, headers }:
|
|
|
34
34
|
*/
|
|
35
35
|
export class Http2Retryable extends Http2Error {
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
38
|
-
* @typedef {object} Http2ConnectionOptions
|
|
39
|
-
* @property {import('../transport.js').ConnectionInfo} [info] provenance attached to responses
|
|
40
|
-
* @property {number} [initialWindowSize] our SETTINGS_INITIAL_WINDOW_SIZE (receive window per
|
|
41
|
-
* stream). Defaults to curl's 10 MiB; tests lower it to exercise flow control.
|
|
42
|
-
* @property {number} [connectionWindow] the connection receive window we open with a WINDOW_UPDATE
|
|
43
|
-
* right after SETTINGS. Defaults to curl's 1000 MiB.
|
|
44
|
-
* @property {number} [maxConcurrentStreams] our advertised SETTINGS_MAX_CONCURRENT_STREAMS.
|
|
45
|
-
* @property {number} [maxHeaderTableSize] our advertised SETTINGS_HEADER_TABLE_SIZE.
|
|
46
|
-
* @property {number} [maxHeaderListSize] self-protection cap on a decoded response header list.
|
|
47
|
-
* @property {(err: Error | null) => void} [onClose] called once when the connection dies, so a
|
|
48
|
-
* registry can drop it.
|
|
49
|
-
*/
|
|
50
37
|
export class Http2Connection {
|
|
51
38
|
/**
|
|
52
39
|
* @param {import('../tls/connect.js').ByteDuplex | { readable: ReadableStream<Uint8Array>,
|
|
@@ -83,8 +70,10 @@ export class Http2Connection {
|
|
|
83
70
|
_continuation: {
|
|
84
71
|
streamId: any;
|
|
85
72
|
fragments: Uint8Array<ArrayBuffer>[];
|
|
73
|
+
bytes: number;
|
|
86
74
|
endStream: boolean;
|
|
87
75
|
} | null;
|
|
76
|
+
_maxHeaderBlockBytes: number;
|
|
88
77
|
_expectFirstSettings: boolean;
|
|
89
78
|
_fatal: any;
|
|
90
79
|
_goaway: {
|
|
@@ -137,6 +126,9 @@ export class Http2Connection {
|
|
|
137
126
|
/** @type {Uint8Array[]} */
|
|
138
127
|
recvQueue: Uint8Array[];
|
|
139
128
|
recvEnded: boolean;
|
|
129
|
+
/** @type {number | null} declared content-length, null when absent */
|
|
130
|
+
declaredLength: number | null;
|
|
131
|
+
receivedLength: number;
|
|
140
132
|
/** @type {Error | null} */
|
|
141
133
|
bodyError: Error | null;
|
|
142
134
|
/** @type {(() => void) | null} */
|
|
@@ -187,6 +179,14 @@ export class Http2Connection {
|
|
|
187
179
|
_dispatchFrame(frame: any): void;
|
|
188
180
|
_onSettings(flags: any, streamId: any, payload: any): void;
|
|
189
181
|
_onHeaders(flags: any, streamId: any, payload: any): void;
|
|
182
|
+
/**
|
|
183
|
+
* Enforce the raw header-block cap, killing the connection when it is passed.
|
|
184
|
+
* Connection-level rather than stream-level on purpose: the fragments are HPACK input, and
|
|
185
|
+
* abandoning a partial block would leave the shared decoder desynchronised for every other
|
|
186
|
+
* stream — which RFC 9113 s4.3 makes a connection error in its own right.
|
|
187
|
+
* @returns {boolean} true when assembly may continue
|
|
188
|
+
*/
|
|
189
|
+
_headerBlockWithinCap(): boolean;
|
|
190
190
|
_onContinuation(flags: any, payload: any): void;
|
|
191
191
|
/** A full header block has been assembled: HPACK-decode it (connection-fatal on failure, since
|
|
192
192
|
* HPACK state is shared) and route it to the stream as a response head or as trailers. */
|
|
@@ -225,24 +225,6 @@ export class Http2Connection {
|
|
|
225
225
|
_settleResolve(d: any, value: any): void;
|
|
226
226
|
_settleReject(d: any, err: any): void;
|
|
227
227
|
}
|
|
228
|
-
export type BodyStream = ReadableStream<Uint8Array> & {
|
|
229
|
-
completed: Promise<boolean>;
|
|
230
|
-
trailers: Promise<Headers | null>;
|
|
231
|
-
};
|
|
232
|
-
export type Http2ResponseHead = {
|
|
233
|
-
status: number;
|
|
234
|
-
/**
|
|
235
|
-
* always '' — HTTP/2 has no reason phrase
|
|
236
|
-
*/
|
|
237
|
-
statusText: string;
|
|
238
|
-
headers: Headers;
|
|
239
|
-
/**
|
|
240
|
-
* one entry per set-cookie field, kept separate like the h1 path
|
|
241
|
-
*/
|
|
242
|
-
setCookie: string[];
|
|
243
|
-
httpVersion: "2";
|
|
244
|
-
body: BodyStream;
|
|
245
|
-
};
|
|
246
228
|
export type Http2ConnectionOptions = {
|
|
247
229
|
/**
|
|
248
230
|
* provenance attached to responses
|
|
@@ -270,12 +252,37 @@ export type Http2ConnectionOptions = {
|
|
|
270
252
|
* self-protection cap on a decoded response header list.
|
|
271
253
|
*/
|
|
272
254
|
maxHeaderListSize?: number | undefined;
|
|
255
|
+
/**
|
|
256
|
+
* cap on the RAW bytes of one HEADERS+CONTINUATION run,
|
|
257
|
+
* before HPACK decoding. Default 262144, matching the decoded cap. This is the bound that stops
|
|
258
|
+
* a CONTINUATION flood; `maxHeaderListSize` cannot, because it is only reachable once the whole
|
|
259
|
+
* block has been assembled in memory.
|
|
260
|
+
*/
|
|
261
|
+
maxHeaderBlockBytes?: number | undefined;
|
|
273
262
|
/**
|
|
274
263
|
* called once when the connection dies, so a
|
|
275
264
|
* registry can drop it.
|
|
276
265
|
*/
|
|
277
266
|
onClose?: ((err: Error | null) => void) | undefined;
|
|
278
267
|
};
|
|
268
|
+
export type BodyStream = ReadableStream<Uint8Array> & {
|
|
269
|
+
completed: Promise<boolean>;
|
|
270
|
+
trailers: Promise<Headers | null>;
|
|
271
|
+
};
|
|
272
|
+
export type Http2ResponseHead = {
|
|
273
|
+
status: number;
|
|
274
|
+
/**
|
|
275
|
+
* always '' — HTTP/2 has no reason phrase
|
|
276
|
+
*/
|
|
277
|
+
statusText: string;
|
|
278
|
+
headers: Headers;
|
|
279
|
+
/**
|
|
280
|
+
* one entry per set-cookie field, kept separate like the h1 path
|
|
281
|
+
*/
|
|
282
|
+
setCookie: string[];
|
|
283
|
+
httpVersion: "2";
|
|
284
|
+
body: BodyStream;
|
|
285
|
+
};
|
|
279
286
|
import { Http2Error } from '../errors.js';
|
|
280
287
|
import { ByteReader } from '../util/bytes.js';
|
|
281
288
|
import { ByteWriter } from '../util/bytes.js';
|