From fa8ffdb13ffa3ab3170df753e4c09c165e518089 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Fri, 30 Oct 2026 13:38:43 -0400 Subject: [PATCH] http2: allow client request header injection via diagnostics_channel The http2.client.stream.created and http2.client.stream.start channels publish the stream's sentHeaders object, but by that point the request headers have already been serialized in ClientHttp2Session#request(). Mutating the published object has no effect on the wire, so tracing instrumentation has no way to add context headers such as traceparent to outgoing HTTP/2 client requests. Add a new http2.client.request.headers channel, published when ClientHttp2Session#request() is called, before the headers are validated and serialized. Subscribers receive a shallow copy of the headers object passed by the caller and can mutate it in place to add or modify request headers. When request() is called without headers, a fresh object is published so subscribers can still inject headers. The caller's own headers object and its sensitiveHeaders list are never modified, so header objects can be safely reused or forwarded across requests. Injected headers go through the same validation as headers passed directly to request(). The existing created and start channels are unchanged. Assisted-by: pi Signed-off-by: Bryan English --- doc/api/diagnostics_channel.md | 19 ++ lib/internal/http2/core.js | 30 ++- ...cs-channel-http2-client-request-headers.js | 234 ++++++++++++++++++ 3 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-diagnostics-channel-http2-client-request-headers.js diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index 25e544ccf27b..d1583122535b 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1699,6 +1699,25 @@ Emitted when server sends a response. > Stability: 1 - Experimental +##### Event: `'http2.client.request.headers'` + +* `headers` {HTTP/2 Headers Object | string\[]} +* `session` {http2.ClientHttp2Session} + +Emitted when `ClientHttp2Session#request()` is called, before the request +headers are validated and serialized. The `headers` object is a copy of the +one passed to `request()` by the caller (or a newly created object when no +headers were passed), and it can be mutated in place to add or modify request +headers before they are sent. Mutating it does not affect the headers +object passed by the caller. This allows subscribers such as tracing +instrumentation to inject context headers (e.g. `traceparent`) into outgoing +HTTP/2 requests. +Headers injected this way are subject to the same validation as headers +passed directly to `request()`. To take effect, headers must be mutated in +place. Unlike `'http2.client.stream.created'`, which is published after +the request headers have been serialized, mutations made by subscribers to +this channel affect the headers that are sent. + ##### Event: `'http2.client.stream.created'` * `stream` {ClientHttp2Stream} diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 011cf22210ff..5f7f1f6d5073 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -197,6 +197,7 @@ const { StreamPipe } = internalBinding('stream_pipe'); const { _connectionListener: httpConnectionListener } = http; const dc = require('diagnostics_channel'); +const onClientRequestHeadersChannel = dc.channel('http2.client.request.headers'); const onClientStreamCreatedChannel = dc.channel('http2.client.stream.created'); const onClientStreamStartChannel = dc.channel('http2.client.stream.start'); const onClientStreamErrorChannel = dc.channel('http2.client.stream.error'); @@ -1906,6 +1907,25 @@ class ClientHttp2Session extends Http2Session { this[kUpdateTimer](); + let headers = headersParam; + if (onClientRequestHeadersChannel.hasSubscribers && + (headers === undefined || + ArrayIsArray(headers) || + (!!headers && typeof headers === 'object'))) { + if (headers === undefined) { + headers = { __proto__: null }; + } else { + headers = ArrayIsArray(headers) ? + ArrayFrom(headers) : + ObjectAssign({ __proto__: null }, headers); + if (headersParam[kSensitiveHeaders] !== undefined) { + headers[kSensitiveHeaders] = + ArrayFrom(headersParam[kSensitiveHeaders]); + } + } + onClientRequestHeadersChannel.publish({ headers, session: this }); + } + let headersList; let headersObject; let rawHeaders; @@ -1913,23 +1933,23 @@ class ClientHttp2Session extends Http2Session { let authority; let method; - if (ArrayIsArray(headersParam)) { + if (ArrayIsArray(headers)) { ({ rawHeaders, headersList, scheme, authority, method, - } = prepareRequestHeadersArray(headersParam, this)); - } else if (!!headersParam && typeof headersParam === 'object') { + } = prepareRequestHeadersArray(headers, this)); + } else if (!!headers && typeof headers === 'object') { ({ headersObject, headersList, scheme, authority, method, - } = prepareRequestHeadersObject(headersParam, this)); - } else if (headersParam === undefined) { + } = prepareRequestHeadersObject(headers, this)); + } else if (headers === undefined) { ({ headersObject, headersList, diff --git a/test/parallel/test-diagnostics-channel-http2-client-request-headers.js b/test/parallel/test-diagnostics-channel-http2-client-request-headers.js new file mode 100644 index 000000000000..8287d3daa7ba --- /dev/null +++ b/test/parallel/test-diagnostics-channel-http2-client-request-headers.js @@ -0,0 +1,234 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { once } = require('events'); +const dc = require('diagnostics_channel'); +const http2 = require('http2'); + +const kPublishCount = 8; + +let currentSession; +let injection; + +dc.subscribe('http2.client.request.headers', common.mustCall(({ headers, session }) => { + assert.strictEqual(session, currentSession); + assert.strictEqual(session.constructor.name, 'ClientHttp2Session'); + assert.ok(!!headers && typeof headers === 'object'); + if (injection !== undefined) + injection(headers); +}, kPublishCount)); + +function doRequest(client, headers) { + return new Promise((resolve, reject) => { + const stream = client.request(headers); + stream.setEncoding('utf8'); + let body = ''; + stream.on('data', (chunk) => { body += chunk; }); + stream.on('end', () => resolve(JSON.parse(body))); + stream.on('error', reject); + }); +} + +async function main() { + const server = http2.createServer(); + server.on('stream', (stream, headers) => { + // Echo the received request headers back in the response body. + stream.respond({ 'content-type': 'application/json' }); + stream.end(JSON.stringify(headers)); + }); + server.listen(0); + await once(server, 'listening'); + const port = server.address().port; + + // Scenario 1: request() called while the session is still connecting, + // with an object headers argument. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + injection = (headers) => { + headers.traceparent = '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'; + }; + // Also make sure the snapshot published to 'http2.client.stream.created' + // reflects the injected header, since serialization happens after the + // 'http2.client.request.headers' publish. + const onCreated = common.mustCall(({ headers }) => { + assert.strictEqual( + headers.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + }, 1); + dc.subscribe('http2.client.stream.created', onCreated); + const origHeaders = { ':path': '/connecting' }; + const received = await doRequest(client, origHeaders); + dc.unsubscribe('http2.client.stream.created', onCreated); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + assert.strictEqual(received[':path'], '/connecting'); + // The subscriber mutates a copy, so the caller's object is unchanged. + assert.deepStrictEqual(origHeaders, { ':path': '/connecting' }); + client.close(); + await once(client, 'close'); + } + + // Scenario 2: request() called on an already-connected session. + { + const client = http2.connect(`http://localhost:${port}`); + await once(client, 'connect'); + currentSession = client; + injection = (headers) => { + headers.traceparent = '00-0af7651916cd43dd8f8ba5c1d1f77f0f-b7ad6b7169203331-01'; + }; + const received = await doRequest(client, { ':path': '/connected' }); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-b7ad6b7169203331-01'); + assert.strictEqual(received[':path'], '/connected'); + client.close(); + await once(client, 'close'); + } + + // Scenario 3: no headers argument at all. The subscriber must still be + // able to inject headers into the fresh object that is published. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + let sawFreshObject; + injection = (headers) => { + sawFreshObject = headers[':path'] === undefined; + headers.traceparent = '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'; + }; + const received = await doRequest(client); + assert.ok(sawFreshObject); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + assert.strictEqual(received[':path'], '/'); + client.close(); + await once(client, 'close'); + } + + // Scenario 4: a subscriber that does not mutate the headers must have no + // effect on the request. + { + const client = http2.connect(`http://localhost:${port}`); + await once(client, 'connect'); + currentSession = client; + injection = undefined; + const received = await doRequest(client, { + ':path': '/no-mutation', + 'x-test': 'value', + }); + assert.strictEqual(received.traceparent, undefined); + assert.deepStrictEqual(received, { + ':method': 'GET', + ':scheme': 'http', + ':authority': `localhost:${port}`, + ':path': '/no-mutation', + 'x-test': 'value', + }); + client.close(); + await once(client, 'close'); + } + + // Scenario 5: an array of raw headers can be mutated in place. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + const origHeaders = [':path', '/array']; + let sawArray; + injection = (headers) => { + sawArray = Array.isArray(headers); + headers.push('traceparent', '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + }; + const received = await doRequest(client, origHeaders); + assert.ok(sawArray); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + assert.strictEqual(received[':path'], '/array'); + // The subscriber mutates a copy, so the caller's array is unchanged. + assert.deepStrictEqual(origHeaders, [':path', '/array']); + client.close(); + await once(client, 'close'); + } + + // Scenario 6: injected headers go through the same validation as headers + // passed directly to request(). Injecting an unknown pseudo-header throws + // the same error that passing it directly would. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + injection = (headers) => { + headers[':foo'] = 'invalid'; + }; + assert.throws(() => client.request({ ':path': '/invalid' }), + { code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' }); + injection = undefined; + client.destroy(); + } + + // Scenario 7: sensitiveHeaders is preserved on the published copy, in + // object form, and mutating it does not touch the caller's list. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + const origHeaders = { + ':path': '/sensitive', + 'authorization': 'secret', + [http2.sensitiveHeaders]: ['authorization'], + }; + let sawSensitive; + injection = (headers) => { + sawSensitive = headers[http2.sensitiveHeaders]?.slice(); + headers[http2.sensitiveHeaders].push('traceparent'); + headers.traceparent = '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'; + }; + const received = await doRequest(client, origHeaders); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + assert.deepStrictEqual(sawSensitive, ['authorization']); + assert.deepStrictEqual(origHeaders, { + ':path': '/sensitive', + 'authorization': 'secret', + [http2.sensitiveHeaders]: ['authorization'], + }); + client.close(); + await once(client, 'close'); + } + + // Scenario 8: same as scenario 7, in raw array form. + { + const client = http2.connect(`http://localhost:${port}`); + currentSession = client; + const origHeaders = [':path', '/sensitive-array', 'authorization', 'secret']; + origHeaders[http2.sensitiveHeaders] = ['authorization']; + let sawArray; + let sawSensitive; + injection = (headers) => { + sawArray = Array.isArray(headers); + sawSensitive = headers[http2.sensitiveHeaders]?.slice(); + headers[http2.sensitiveHeaders].push('traceparent'); + headers.push('traceparent', '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + }; + const received = await doRequest(client, origHeaders); + assert.strictEqual( + received.traceparent, + '00-0af7651916cd43dd8f8ba5c1d1f77f0f-00f067aa0ba902b7-01'); + assert.ok(sawArray); + assert.deepStrictEqual(sawSensitive, ['authorization']); + assert.strictEqual(origHeaders.length, 4); + assert.deepStrictEqual(origHeaders[http2.sensitiveHeaders], ['authorization']); + client.close(); + await once(client, 'close'); + } + + server.close(); + await once(server, 'close'); +} + +main().then(common.mustCall());