h2lean 0.0.0-stage → 0.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/lib/headers.js ADDED
@@ -0,0 +1,463 @@
1
+ // Response/trailer header validation and HPACK block construction.
2
+ //
3
+ // The optimisation that matters here is granularity: encoding one field line at a
4
+ // time costs a key string plus a Map lookup each, while encoding the *whole block*
5
+ // once and memcpy-ing it is nearly free (OPTIMIZATION.md item 6). So the cache
6
+ // is keyed by the identity of the caller's headers object and holds the finished
7
+ // HPACK bytes.
8
+ //
9
+ // That is only sound when the object cannot change underneath us, so caching is
10
+ // restricted to frozen objects (Object.isSealed is not enough: sealed objects still
11
+ // allow a value to be reassigned). Non-frozen objects are encoded fresh on every call,
12
+ // exactly like node does. And because the encoder keeps an HPACK dynamic table, the
13
+ // bytes also depend on the connection they go out on: the cache lives on the session.
14
+ 'use strict';
15
+
16
+ const { Encoder } = require('./hpack');
17
+ const { sensitiveHeaders: KSensitiveHeaders } = require('node:http2');
18
+
19
+ // A header map with no prototype.
20
+ //
21
+ // The prototype is taken away after the object is built rather than never set,
22
+ // because V8 keeps such an object in fast-properties mode, where the stores and
23
+ // the keyed reads cost less than on a dictionary-mode one (OPTIMIZATION.md item 4;
24
+ // the measured numbers live there, not here).
25
+ //
26
+ // The prototype still has to be null: a field named "__proto__" must become an
27
+ // ordinary field and not a write to the map's prototype, and a map that keeps
28
+ // Object.prototype would answer "constructor" or "toString" for a field that
29
+ // was never set -- two readers of one map disagreeing about what it carries.
30
+ // node's own header objects are { __proto__: null } literals
31
+ // (internal/http2/util.js, toHeaderObject), and the two shapes are
32
+ // indistinguishable from the outside: same prototype, same enumeration order,
33
+ // same JSON, same freeze behaviour (bench/micro/nullproto-semantics.js).
34
+ //
35
+ // A fast map hangs on the process-wide transition tree of its root map, and the
36
+ // field names come from the peer -- so the question is whether that tree can
37
+ // grow without bound. It cannot: V8 rehomes the root map once its transition
38
+ // array fills (observed: "Transitions #1536"), and the retained heap is flat
39
+ // across 1.8 million distinct six-field name sets (bench/micro/nullproto-root.js,
40
+ // bench/micro/nullproto-tree2.js, bench/micro/nullproto-tree3.js).
41
+ function nullProto() {
42
+ return Object.setPrototypeOf({}, null);
43
+ }
44
+
45
+ // Encoded bytes for one response, reused between calls. The encoder that fills it
46
+ // belongs to the session (lib/session), because a dynamic table is per-connection
47
+ // state, so nothing on this path is shared between sessions.
48
+ // A response block lives for microseconds: _frame() copies it into the staging buffer
49
+ // of the outbound queue, so the queue never holds a view of this scratch. See block()
50
+ // before holding on to the returned view across another call.
51
+ let scratch = Buffer.allocUnsafe(4096);
52
+
53
+ const isToken = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
54
+ // Whether a field name is legal depends only on its characters, so the names already
55
+ // accepted are remembered and the regex is not run again for them (OPTIMIZATION.md
56
+ // item 7), which a compat
57
+ // response pays for every header it sends because its header object is not frozen
58
+ // and cannot be cached.
59
+ // Bounded: an application that reflects untrusted input into header names would
60
+ // otherwise grow the cache for the life of the process, and a name that is new to a
61
+ // full cache costs the regex and nothing else -- the same as before this existed.
62
+ // A Set rather than an object: 'constructor' and '__proto__' must not be special, and
63
+ // the object form is the slower one (OPTIMIZATION.md item 7).
64
+ const VALID_NAMES = new Set();
65
+ const VALID_NAMES_MAX = 1024;
66
+
67
+ // RFC 9113 8.2.1: a field name is a token, and it is lower case. The token check is not
68
+ // cosmetic -- a name carrying CR, LF or NUL is how one header becomes two *lines* again
69
+ // once a proxy translates this connection to HTTP/1.1, which is the class of CVE-2020-8287
70
+ // (Node.js read one of two copies of a field and ignored the other, and the same split
71
+ // happens when a separator is smuggled inside a field). Refusing the name here, before a
72
+ // single octet is produced, is the whole mitigation: the application cannot build a header
73
+ // that a downstream reader would parse differently. RFC 9113 10.3 calls out exactly this
74
+ // HTTP/2-to-HTTP/1.1 translation risk as the reason the rules are strict.
75
+ function isFieldName(key) {
76
+ if (VALID_NAMES.has(key)) {
77
+ return true;
78
+ }
79
+ if (!isToken.test(key)) {
80
+ return false;
81
+ }
82
+ if (VALID_NAMES.size < VALID_NAMES_MAX) {
83
+ VALID_NAMES.add(key);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ // A field name in the form the wire wants it.
89
+ //
90
+ // Two shapes, and they are not the same one: the compat layer's name is
91
+ // trimmed and lower case (node's compat layer does `name.trim().toLowerCase()`,
92
+ // internal/http2/compat.js), the encoder's is lower case alone (node's
93
+ // buildNgHeaderString does `key.toLowerCase()`, internal/http2/util.js).
94
+ //
95
+ // Both are cached. A name that is already in the wanted shape costs nothing to
96
+ // produce -- trim() and toLowerCase() return the very same string object --
97
+ // so what is left is the call itself -- a Map lookup beats the two calls it
98
+ // replaces (OPTIMIZATION.md item 8).
99
+ //
100
+ // Bounded, and it never evicts, like VALID_NAMES above: the names come from
101
+ // application code, and an application that reflects untrusted input into
102
+ // header names would otherwise grow the cache for the life of the process.
103
+ // The bound is the answer to that, and a better one than a periodic reset: a
104
+ // reset needs a timer (a live handle) and throws away the hot entries, while
105
+ // the bound caps the damage and keeps what is hot. A name that is new to a
106
+ // full cache costs the lookup and the normalisation, which is what a hit
107
+ // costs anyway.
108
+ //
109
+ // The miss is the expensive direction, and the insert into a growing Map is what
110
+ // makes it so (OPTIMIZATION.md item 8). So the cache pays for the names an application
111
+ // writes as literals, whose hash V8 computes once and keeps in the string, and it
112
+ // loses for the names it builds fresh every time.
113
+ const NAME_CACHE = new Map();
114
+ const LOWER_CACHE = new Map();
115
+ const NAME_CACHE_MAX = 1024;
116
+
117
+ // The compat layer's shape. A name that is not a string throws here, the way
118
+ // node's private kSetHeader does (name.trim() is not a function), and the
119
+ // public accessors in lib/compat.js refuse it before they get here.
120
+ function trimName(name) {
121
+ let out = NAME_CACHE.get(name);
122
+ if (out === undefined) {
123
+ out = name.trim().toLowerCase();
124
+ if (NAME_CACHE.size < NAME_CACHE_MAX) {
125
+ NAME_CACHE.set(name, out);
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // The encoder's shape: lower case, not trimmed. Trimming here would change
132
+ // what the encoder accepts -- a name with a space around it is a token error
133
+ // today, and it has to stay one (RFC 9113 8.2.1).
134
+ function lowerName(name) {
135
+ let out = LOWER_CACHE.get(name);
136
+ if (out === undefined) {
137
+ out = name.toLowerCase();
138
+ if (LOWER_CACHE.size < NAME_CACHE_MAX) {
139
+ LOWER_CACHE.set(name, out);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ // The size of the two caches, for the test that pins the bound. The caches are
146
+ // module state, and a bound that nothing can see is a bound nothing checks.
147
+ function nameCacheSize() {
148
+ return { names: NAME_CACHE.size, lowers: LOWER_CACHE.size };
149
+ }
150
+
151
+ // RFC 9113 8.2: a field value may not contain NUL, LF or CR (HTAB is allowed), and no
152
+ // other control character -- the same injection class as the name check above, seen from
153
+ // the value side. It is also what makes a value safe to use as a map key: the encoder's
154
+ // byte cache is keyed by the name and then by the value (lib/hpack.js), so the two are
155
+ // never concatenated and no separator can be spoofed -- but a key shape that does
156
+ // concatenate them would need this check to stay.
157
+ //
158
+ // The class is the one nghttp2 applies to an INBOUND value, measured against a live
159
+ // node server (bench/micro/probe-value-matrix.js): every control character but HTAB, and DEL.
160
+ // A field whose value carries one is ignored (NGHTTP2_ERR_IGN_HTTP_HEADER) and the
161
+ // stream lives -- the request is served, without the field.
162
+ //
163
+ // The OUTBOUND check is h2lean's own, and stricter than node's: node does not look at
164
+ // an outbound value at all and puts a CR on the wire (bench/micro/probe-out-value-matrix.js --
165
+ // every one of the fifteen characters tried is accepted, and the CR arrives in the
166
+ // HEADERS frame). Refusing here is the mitigation the CVE-2020-8287 class needs: the
167
+ // value never becomes an octet, so a translator behind this server cannot read it
168
+ // differently than we do. It costs an application that sets such a value a refusal
169
+ // node does not make.
170
+ const badValue = /[\u0000-\u0008\u000a-\u001f\u007f]/;
171
+
172
+ // Kept as a Set: at five names the shape of the operation decides it, not the
173
+ // size (OPTIMIZATION.md item 10).
174
+ // RFC 9113 8.2.2 forbids these on an HTTP/2 connection: they are hop-by-hop in HTTP/1.1,
175
+ // so sending one would either be ignored by the peer (a lie to the application) or
176
+ // forwarded by a downstream translator (CVE-2020-8287's class: two readers, one field).
177
+ const CONNECTION_SPECIFIC = new Set(['connection', 'keep-alive', 'proxy-connection',
178
+ 'transfer-encoding', 'upgrade']);
179
+
180
+ // Whether a field value carries a character that may not appear in one. The server's
181
+ // inbound path asks the same question (lib/session.js) and answers it the way nghttp2
182
+ // does: the field is ignored, the stream lives.
183
+ function hasBadValue(value) {
184
+ return badValue.test(value);
185
+ }
186
+
187
+ function httpError(code, msg) {
188
+ const err = new Error(msg);
189
+ err.code = code;
190
+ return err;
191
+ }
192
+
193
+ // node adds a `date` header to every response unless the application set one or
194
+ // turned sendDate off. The value only changes once a second, so it is cached -- and
195
+ // the cache is invalidated by a one-shot unref'd timer rather than compared against
196
+ // the clock on every call, which is what node's own HTTP/1.1 date cache does
197
+ // (internal/http; OPTIMIZATION.md item 9 for the measurements). An idle server holds
198
+ // no timer at all; a refresh happens on the next response after the boundary, which
199
+ // can be a little late if the loop was busy -- exactly as in node.
200
+ let dateString;
201
+ let dateTimer = null;
202
+ let dateSecond = 0; // counts the boundaries crossed, see touchDate()
203
+ function httpDate() {
204
+ if (dateString === undefined) {
205
+ refreshDate();
206
+ }
207
+ return dateString;
208
+ }
209
+
210
+ // Refreshes the string if the second has moved and reports which second that is. A block
211
+ // cache entry records it, because the generated date is the one field whose value is not
212
+ // fixed by the header object the entry is keyed on. Callers read the stamp *before* the
213
+ // value: a boundary can then only make the value newer than its stamp, which costs one
214
+ // re-encode, never one stale response.
215
+ function touchDate() {
216
+ if (dateString === undefined) {
217
+ refreshDate();
218
+ }
219
+ return dateSecond;
220
+ }
221
+
222
+ function refreshDate() {
223
+ const now = new Date();
224
+ dateString = now.toUTCString();
225
+ dateSecond++;
226
+ const ms = now.getMilliseconds();
227
+ dateTimer = setTimeout(() => {
228
+ dateString = undefined;
229
+ dateTimer = null;
230
+ }, ms === 0 ? 1000 : 1000 - ms);
231
+ dateTimer.unref();
232
+ }
233
+
234
+ // -> { status, pseudo: [name, value, ...], fields: [name, value, never, ...] }
235
+ // The two lists are flat: a field line is three slots in one array, not an array of
236
+ // its own. The compat API parses the headers of every response, and a response with
237
+ // five headers used to allocate five arrays plus the two lists that hold them; the
238
+ // flat form allocates the two lists and nothing else, and the loops that read them
239
+ // need no iterator.
240
+ // `info` marks the informational (1xx) block that additionalHeaders() sends: node applies
241
+ // a different status rule and different error codes there, and it generates no date for it.
242
+ function parse(headers, sensitive, { trailer = false, sendDate = false, info = false } = {}) {
243
+ if (headers === null || typeof headers !== 'object') {
244
+ throw httpError('ERR_HTTP2_INVALID_HEADER_VALUE', 'headers must be an object');
245
+ }
246
+ const neverSet = sensitive || headers[KSensitiveHeaders];
247
+ const neverList = Array.isArray(neverSet) && neverSet.length > 0
248
+ ? new Set(neverSet.map((s) => lowerName(s)))
249
+ : null;
250
+
251
+ const pseudo = [];
252
+ const fields = [];
253
+ let status;
254
+ let hasDate = false;
255
+ let sawStatus = false;
256
+ const keys = Object.keys(headers);
257
+ for (let i = 0; i < keys.length; i++) {
258
+ const key = lowerName(keys[i]);
259
+ const raw = headers[keys[i]];
260
+ // Only an absent value is skipped. An empty name is not: node rejects it with
261
+ // ERR_INVALID_HTTP_TOKEN, and the token check below does the same, because the
262
+ // pattern requires at least one character.
263
+ if (raw === undefined) {
264
+ continue;
265
+ }
266
+
267
+ if (key[0] === ':') {
268
+ if (trailer) {
269
+ throw httpError('ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED', 'cannot send trailers with pseudo-headers');
270
+ }
271
+ if (key !== ':status') {
272
+ throw httpError('ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED', `cannot send ${key} in a response`);
273
+ }
274
+ const single = Array.isArray(raw) ? raw[0] : raw;
275
+ // node coerces with `statusCode | 0` -- not Number(), and not a range check on the
276
+ // string: '200.5' is 200, 1.9 is 1, 'abc' is 0. Both paths then add their own rule,
277
+ // measured against a live node server:
278
+ // respond(): 0 becomes 200, anything outside 200..599 throws
279
+ // ERR_HTTP2_STATUS_INVALID (core.js:2700 and 2747), so 103
280
+ // and 600 are refused here although this used to accept both;
281
+ // additionalHeaders(): 101 is ERR_HTTP2_STATUS_101 and every other value outside
282
+ // 100..199 is ERR_HTTP2_INVALID_INFO_STATUS (core.js:3268-3274).
283
+ // The value that goes on the wire is the coerced one, which is why :status is
284
+ // emitted below, after the default and the range have been decided.
285
+ const code = Number(single) | 0;
286
+ if (info) {
287
+ // 101 is Switching Protocols, which HTTP/2 forbids outright (RFC 9113 8.5): an
288
+ // upgrade can only be expressed as a connection-specific field, and RFC 9113 8.2.2
289
+ // removes those from this protocol.
290
+ if (code === 101) {
291
+ throw httpError('ERR_HTTP2_STATUS_101', 'HTTP status code 101 is disallowed');
292
+ }
293
+ if (code < 100 || code >= 200) {
294
+ throw httpError('ERR_HTTP2_INVALID_INFO_STATUS',
295
+ `invalid informational status code: ${single}`);
296
+ }
297
+ }
298
+ status = code;
299
+ sawStatus = true;
300
+ continue;
301
+ }
302
+
303
+ if (!isFieldName(key)) {
304
+ throw httpError('ERR_INVALID_HTTP_TOKEN', `${key} is an invalid header token`);
305
+ }
306
+ if (CONNECTION_SPECIFIC.has(key)) {
307
+ throw httpError('ERR_HTTP2_INVALID_CONNECTION_HEADERS',
308
+ `HTTP/1 Connection specific header is forbidden: ${key}`);
309
+ }
310
+ const never = neverList !== null && neverList.has(key);
311
+ if (Array.isArray(raw)) {
312
+ for (let j = 0; j < raw.length; j++) {
313
+ fields.push(key, checked(key, raw[j]), never);
314
+ }
315
+ if (key === 'date') {
316
+ hasDate = true;
317
+ }
318
+ } else {
319
+ fields.push(key, checked(key, raw), never);
320
+ if (key === 'date') {
321
+ hasDate = true;
322
+ }
323
+ }
324
+ }
325
+
326
+ // A response without :status is not an error in node: the same function that adds the
327
+ // date does `statusCode | 0 || HTTP_STATUS_OK`, so a missing (or NaN-ish) status
328
+ // answers 200. Measured: respond({'x-a': 1}) arrives as :status 200 with a date, where
329
+ // this used to throw. An informational block has no such default -- node validates a
330
+ // :status only if the application put one there -- and trailers have no status at all.
331
+ if (!trailer) {
332
+ if (info) {
333
+ if (sawStatus) {
334
+ pseudo.push(':status', String(status));
335
+ }
336
+ } else {
337
+ status = status || 200;
338
+ if (status < 200 || status > 599) {
339
+ throw httpError('ERR_HTTP2_STATUS_INVALID', `invalid status code: ${status}`);
340
+ }
341
+ pseudo.push(':status', String(status));
342
+ }
343
+ }
344
+ // node's rule (`internal/http2/core.js:2702`) is: no date when the application already
345
+ // set one, none when options.sendDate says so, and no status condition at all -- because
346
+ // respond() cannot carry a 1xx in the first place (node rejects :status 103 with
347
+ // ERR_HTTP2_STATUS_INVALID), and an informational block goes through additionalHeaders(),
348
+ // which asks for no date. Appended last, because that is where node's respond() puts it
349
+ // and header order is visible on the wire.
350
+ if (!trailer && !info && sendDate && status >= 200 && !hasDate) {
351
+ fields.push('date', httpDate());
352
+ }
353
+ if (status === 204 || status === 304 || (status >= 100 && status < 200)) {
354
+ for (let i = 0; i < fields.length; i += 3) {
355
+ if (fields[i] === 'content-length' && fields[i + 1] !== '0') {
356
+ throw httpError('ERR_HTTP2_PAYLOAD_FORBIDDEN',
357
+ `response with ${status} status must not declare a payload`);
358
+ }
359
+ }
360
+ }
361
+ return { status, pseudo, fields };
362
+ }
363
+
364
+ function checked(key, value) {
365
+ // String() on a string returns the very same string, so the call is only worth
366
+ // its price for the other types: the check below is the expensive half.
367
+ const v = typeof value === 'string' ? value : String(value);
368
+ if (badValue.test(v)) {
369
+ throw httpError('ERR_HTTP2_INVALID_HEADER_VALUE', `invalid value "${v}" for header "${key}"`);
370
+ }
371
+ return v;
372
+ }
373
+
374
+ // The HPACK bytes for one HEADERS payload. The encoder carries a dynamic table, which
375
+ // is per-connection state, so the block cache lives on the session and is stamped with
376
+ // the table's epoch: cached bytes may name an index that a later insertion moved.
377
+ // The returned Buffer is a view into a shared scratch block: valid until the
378
+ // next call to block(), which is why every caller copies it immediately.
379
+ function block(headers, sensitive, opts, session) {
380
+ const sendDate = !!(opts && opts.sendDate);
381
+ const enc = session._encoder;
382
+ // The second the generated date belongs to, or -1 when the block carries none. A
383
+ // `date` block is cached for its own second and no longer: the value cannot change
384
+ // inside it, and across a boundary the entry has to be rebuilt rather than repeated.
385
+ const stamp = sendDate ? touchDate() : -1;
386
+ // A block carrying a pending size update is never cached: the announcement has to reach
387
+ // the peer with the next block rather than be swallowed by a cache hit (RFC 7541 6.3).
388
+ const cacheable = Object.isFrozen(headers) && enc.pendingSize === null;
389
+ if (cacheable) {
390
+ const hit = session._blocks.get(headers);
391
+ if (hit !== undefined && hit.epoch === enc.epoch && hit.stamp === stamp) {
392
+ return hit.bytes;
393
+ }
394
+ }
395
+
396
+ const update = enc.takeSizeUpdate();
397
+ const parsed = parse(headers, sensitive,
398
+ { trailer: !!(opts && opts.trailer), sendDate, info: !!(opts && opts.info) });
399
+
400
+ // A shape reaches its final form over two or three responses, the way any HPACK
401
+ // encoder warms up: the first sends a line as a literal and remembers it, the second
402
+ // inserts it, the third references it. Only the last form is stable enough to cache,
403
+ // so a build that moved the table (`epoch`) or left a line pending promotion
404
+ // (`unsettled`) is re-encoded next response instead. Building twice in one call is
405
+ // not an option: the discarded bytes were the ones that performed the insertion, and
406
+ // dropping them would leave our table ahead of the peer's -- a desynchronisation the
407
+ // peer can only report as COMPRESSION_ERROR.
408
+ const epoch = enc.epoch;
409
+ // The announcement goes in front of the block, in the same buffer: the old form was
410
+ // Buffer.concat([update, out]) -- a second allocation, a copy of the whole block and a
411
+ // temporary array, on the one path where the peer changed the table size. The client
412
+ // does the same thing in its own _encodeRequest (lib/client-session.js).
413
+ const at = update === null ? 0 : update.length;
414
+ const end = encodeAt(parsed, enc, at);
415
+ if (at !== 0) {
416
+ update.copy(scratch, 0);
417
+ }
418
+ if (cacheable && enc.epoch === epoch && !enc.unsettled) {
419
+ // a cached block has to outlive the scratch, so only the cached path copies --
420
+ // allocUnsafe, not Buffer.from(subarray): at the sizes a response block has the
421
+ // pool copy is the cheaper allocation (OPTIMIZATION.md item 18)
422
+ const bytes = Buffer.allocUnsafe(end - at);
423
+ scratch.copy(bytes, 0, at, end);
424
+ session._blocks.set(headers, { bytes, epoch: enc.epoch, stamp });
425
+ }
426
+ return scratch.subarray(0, end);
427
+ }
428
+
429
+ // One pass over the parsed fields. `enc.unsettled` is reported back to block(): some
430
+ // line was seen for the first time and would be encoded differently next time.
431
+ function encode(parsed, enc) {
432
+ return scratch.subarray(0, encodeAt(parsed, enc, 0));
433
+ }
434
+
435
+ // The same pass, written at `at` instead of at the front of the scratch -- the place a
436
+ // size update's octets took, so that the announcement and the block are one buffer and
437
+ // one write. Returns the offset the block ends at.
438
+ function encodeAt(parsed, enc, at) {
439
+ enc.unsettled = false;
440
+ const pseudo = parsed.pseudo;
441
+ const fields = parsed.fields;
442
+ let cap = 16 + at;
443
+ for (let i = 0; i < pseudo.length; i += 2) {
444
+ cap += Encoder.maxBytesOf(pseudo[i], pseudo[i + 1]);
445
+ }
446
+ for (let i = 0; i < fields.length; i += 3) {
447
+ cap += Encoder.maxBytesOf(fields[i], fields[i + 1]);
448
+ }
449
+ if (scratch.length < cap) {
450
+ scratch = Buffer.allocUnsafe(cap);
451
+ }
452
+ let off = at;
453
+ for (let i = 0; i < pseudo.length; i += 2) {
454
+ off = enc.write(scratch, off, pseudo[i], pseudo[i + 1], false);
455
+ }
456
+ for (let i = 0; i < fields.length; i += 3) {
457
+ off = enc.write(scratch, off, fields[i], fields[i + 1], fields[i + 2]);
458
+ }
459
+ return off;
460
+ }
461
+
462
+ module.exports = { block, encode, parse, nullProto, isFieldName, trimName, lowerName,
463
+ nameCacheSize, hasBadValue, CONNECTION_SPECIFIC, KSensitiveHeaders };