From 0ad8c7319d6034fff0175bdf61ba8970db06a8ba Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 5 Oct 2018 15:09:07 -0700 Subject: [PATCH] http2: add RFC 8441 extended connect protocol support PR-URL: https://github.com/nodejs/node/pull/23284 Reviewed-By: Refael Ackermann Reviewed-By: Gus Caplan Reviewed-By: Anna Henningsen Reviewed-By: Colin Ihrig Reviewed-By: Trivikram Kamat --- doc/api/http2.md | 37 +++++++++++++++++- lib/internal/http2/core.js | 3 +- lib/internal/http2/util.js | 22 +++++++++-- src/node_http2.cc | 4 ++ src/node_http2.h | 1 + src/node_http2_state.h | 1 + test/parallel/test-http2-binding.js | 4 +- ...2-connect-method-extended-cant-turn-off.js | 30 ++++++++++++++ .../test-http2-connect-method-extended.js | 39 +++++++++++++++++++ 9 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-http2-connect-method-extended-cant-turn-off.js create mode 100644 test/parallel/test-http2-connect-method-extended.js diff --git a/doc/api/http2.md b/doc/api/http2.md index 698f1bf2d87e58..e1e82c9dce3456 100644 --- a/doc/api/http2.md +++ b/doc/api/http2.md @@ -2278,7 +2278,7 @@ not work. For incoming headers: * The `:status` header is converted to `number`. * Duplicates of `:status`, `:method`, `:authority`, `:scheme`, `:path`, -`age`, `authorization`, `access-control-allow-credentials`, +`:protocol`, `age`, `authorization`, `access-control-allow-credentials`, `access-control-max-age`, `access-control-request-method`, `content-encoding`, `content-language`, `content-length`, `content-location`, `content-md5`, `content-range`, `content-type`, `date`, `dnt`, `etag`, `expires`, `from`, @@ -2335,6 +2335,10 @@ properties. * `maxHeaderListSize` {number} Specifies the maximum size (uncompressed octets) of header list that will be accepted. The minimum allowed value is 0. The maximum allowed value is 232-1. **Default:** `65535`. +* `enableConnectProtocol`{boolean} Specifies `true` if the "Extended Connect + Protocol" defined by [RFC 8441][] is to be enabled. This setting is only + meaningful if sent by the server. Once the `enableConnectProtocol` setting + has been enabled for a given `Http2Session`, it cannot be disabled. All additional properties on the settings object are ignored. @@ -2501,6 +2505,36 @@ req.on('end', () => { req.end('Jane'); ``` +### The Extended CONNECT Protocol + +[RFC 8441][] defines an "Extended CONNECT Protocol" extension to HTTP/2 that +may be used to bootstrap the use of an `Http2Stream` using the `CONNECT` +method as a tunnel for other communication protocols (such as WebSockets). + +The use of the Extended CONNECT Protocol is enabled by HTTP/2 servers by using +the `enableConnectProtocol` setting: + +```js +const http2 = require('http2'); +const settings = { enableConnectProtocol: true }; +const server = http2.createServer({ settings }); +``` + +Once the client receives the `SETTINGS` frame from the server indicating that +the extended CONNECT may be used, it may send `CONNECT` requests that use the +`':protocol'` HTTP/2 pseudo-header: + +```js +const http2 = require('http2'); +const client = http2.connect('http://localhost:8080'); +client.on('remoteSettings', (settings) => { + if (settings.enableConnectProtocol) { + const req = client.request({ ':method': 'CONNECT', ':protocol': 'foo' }); + // ... + } +}); +``` + ## Compatibility API The Compatibility API has the goal of providing a similar developer experience @@ -3361,6 +3395,7 @@ following additional properties: [Readable Stream]: stream.html#stream_class_stream_readable [RFC 7838]: https://tools.ietf.org/html/rfc7838 [RFC 8336]: https://tools.ietf.org/html/rfc8336 +[RFC 8441]: https://tools.ietf.org/html/rfc8441 [Using `options.selectPadding()`]: #http2_using_options_selectpadding [`'checkContinue'`]: #http2_event_checkcontinue [`'request'`]: #http2_event_request diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index b1ed1eee8ff448..b08008857ec062 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -190,6 +190,7 @@ const { HTTP2_HEADER_DATE, HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, + HTTP2_HEADER_PROTOCOL, HTTP2_HEADER_SCHEME, HTTP2_HEADER_STATUS, HTTP2_HEADER_CONTENT_LENGTH, @@ -1450,7 +1451,7 @@ class ClientHttp2Session extends Http2Session { const connect = headers[HTTP2_HEADER_METHOD] === HTTP2_METHOD_CONNECT; - if (!connect) { + if (!connect || headers[HTTP2_HEADER_PROTOCOL] !== undefined) { if (headers[HTTP2_HEADER_AUTHORITY] === undefined) headers[HTTP2_HEADER_AUTHORITY] = this[kAuthority]; if (headers[HTTP2_HEADER_SCHEME] === undefined) diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 99466b36d3b321..94dc1198ea1060 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -20,6 +20,7 @@ const { HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_SCHEME, HTTP2_HEADER_PATH, + HTTP2_HEADER_PROTOCOL, HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, @@ -78,7 +79,8 @@ const kValidPseudoHeaders = new Set([ HTTP2_HEADER_METHOD, HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_SCHEME, - HTTP2_HEADER_PATH + HTTP2_HEADER_PATH, + HTTP2_HEADER_PROTOCOL ]); // This set contains headers that are permitted to have only a single @@ -89,6 +91,7 @@ const kSingleValueHeaders = new Set([ HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_SCHEME, HTTP2_HEADER_PATH, + HTTP2_HEADER_PROTOCOL, HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS, HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE, HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD, @@ -155,7 +158,8 @@ const IDX_SETTINGS_INITIAL_WINDOW_SIZE = 2; const IDX_SETTINGS_MAX_FRAME_SIZE = 3; const IDX_SETTINGS_MAX_CONCURRENT_STREAMS = 4; const IDX_SETTINGS_MAX_HEADER_LIST_SIZE = 5; -const IDX_SETTINGS_FLAGS = 6; +const IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL = 6; +const IDX_SETTINGS_FLAGS = 7; const IDX_SESSION_STATE_EFFECTIVE_LOCAL_WINDOW_SIZE = 0; const IDX_SESSION_STATE_EFFECTIVE_RECV_DATA_LENGTH = 1; @@ -277,6 +281,12 @@ function getDefaultSettings() { settingsBuffer[IDX_SETTINGS_MAX_HEADER_LIST_SIZE]; } + if ((flags & (1 << IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL)) === + (1 << IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL)) { + holder.enableConnectProtocol = + settingsBuffer[IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL]; + } + return holder; } @@ -294,7 +304,8 @@ function getSettings(session, remote) { initialWindowSize: settingsBuffer[IDX_SETTINGS_INITIAL_WINDOW_SIZE], maxFrameSize: settingsBuffer[IDX_SETTINGS_MAX_FRAME_SIZE], maxConcurrentStreams: settingsBuffer[IDX_SETTINGS_MAX_CONCURRENT_STREAMS], - maxHeaderListSize: settingsBuffer[IDX_SETTINGS_MAX_HEADER_LIST_SIZE] + maxHeaderListSize: settingsBuffer[IDX_SETTINGS_MAX_HEADER_LIST_SIZE], + enableConnectProtocol: settingsBuffer[IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL] }; } @@ -329,6 +340,11 @@ function updateSettingsBuffer(settings) { flags |= (1 << IDX_SETTINGS_ENABLE_PUSH); settingsBuffer[IDX_SETTINGS_ENABLE_PUSH] = Number(settings.enablePush); } + if (typeof settings.enableConnectProtocol === 'boolean') { + flags |= (1 << IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL); + settingsBuffer[IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL] = + Number(settings.enableConnectProtocol); + } settingsBuffer[IDX_SETTINGS_FLAGS] = flags; } diff --git a/src/node_http2.cc b/src/node_http2.cc index 44353e2d57414c..2c339d7249562e 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -219,6 +219,7 @@ void Http2Session::Http2Settings::Init() { GRABSETTING(INITIAL_WINDOW_SIZE, "initial window size"); GRABSETTING(MAX_HEADER_LIST_SIZE, "max header list size"); GRABSETTING(ENABLE_PUSH, "enable push"); + GRABSETTING(ENABLE_CONNECT_PROTOCOL, "enable connect protocol"); #undef GRABSETTING @@ -287,6 +288,8 @@ void Http2Session::Http2Settings::Update(Environment* env, fn(**session, NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE); buffer[IDX_SETTINGS_ENABLE_PUSH] = fn(**session, NGHTTP2_SETTINGS_ENABLE_PUSH); + buffer[IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL] = + fn(**session, NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL); } // Initializes the shared TypedArray with the default settings values. @@ -3091,6 +3094,7 @@ void Initialize(Local target, NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE); NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_MAX_FRAME_SIZE); NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE); + NODE_DEFINE_CONSTANT(constants, NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL); NODE_DEFINE_CONSTANT(constants, PADDING_STRATEGY_NONE); NODE_DEFINE_CONSTANT(constants, PADDING_STRATEGY_ALIGNED); diff --git a/src/node_http2.h b/src/node_http2.h index 2ab452bf02aaa8..8ecca63aeb0c0e 100644 --- a/src/node_http2.h +++ b/src/node_http2.h @@ -160,6 +160,7 @@ struct nghttp2_header : public MemoryRetainer { V(AUTHORITY, ":authority") \ V(SCHEME, ":scheme") \ V(PATH, ":path") \ + V(PROTOCOL, ":protocol") \ V(ACCEPT_CHARSET, "accept-charset") \ V(ACCEPT_ENCODING, "accept-encoding") \ V(ACCEPT_LANGUAGE, "accept-language") \ diff --git a/src/node_http2_state.h b/src/node_http2_state.h index 64a0942f7ffa67..d21d0f90096074 100644 --- a/src/node_http2_state.h +++ b/src/node_http2_state.h @@ -15,6 +15,7 @@ namespace http2 { IDX_SETTINGS_MAX_FRAME_SIZE, IDX_SETTINGS_MAX_CONCURRENT_STREAMS, IDX_SETTINGS_MAX_HEADER_LIST_SIZE, + IDX_SETTINGS_ENABLE_CONNECT_PROTOCOL, IDX_SETTINGS_COUNT }; diff --git a/test/parallel/test-http2-binding.js b/test/parallel/test-http2-binding.js index ae19149d1bc74d..6991f98afd72d1 100644 --- a/test/parallel/test-http2-binding.js +++ b/test/parallel/test-http2-binding.js @@ -99,6 +99,7 @@ const expectedHeaderNames = { HTTP2_HEADER_AUTHORITY: ':authority', HTTP2_HEADER_SCHEME: ':scheme', HTTP2_HEADER_PATH: ':path', + HTTP2_HEADER_PROTOCOL: ':protocol', HTTP2_HEADER_DATE: 'date', HTTP2_HEADER_ACCEPT_CHARSET: 'accept-charset', HTTP2_HEADER_ACCEPT_ENCODING: 'accept-encoding', @@ -219,7 +220,8 @@ const expectedNGConstants = { NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS: 3, NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE: 4, NGHTTP2_SETTINGS_MAX_FRAME_SIZE: 5, - NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE: 6 + NGHTTP2_SETTINGS_MAX_HEADER_LIST_SIZE: 6, + NGHTTP2_SETTINGS_ENABLE_CONNECT_PROTOCOL: 8 }; const defaultSettings = { diff --git a/test/parallel/test-http2-connect-method-extended-cant-turn-off.js b/test/parallel/test-http2-connect-method-extended-cant-turn-off.js new file mode 100644 index 00000000000000..f4d033efe65707 --- /dev/null +++ b/test/parallel/test-http2-connect-method-extended-cant-turn-off.js @@ -0,0 +1,30 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); + +const settings = { enableConnectProtocol: true }; +const server = http2.createServer({ settings }); +server.on('stream', common.mustNotCall()); +server.on('session', common.mustCall((session) => { + // This will force the connection to close because once extended connect + // is on, it cannot be turned off. The server is behaving badly. + session.settings({ enableConnectProtocol: false }); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + client.on('remoteSettings', common.mustCall((settings) => { + assert(settings.enableConnectProtocol); + const req = client.request({ + ':method': 'CONNECT', + ':protocol': 'foo' + }); + req.on('error', common.mustCall(() => { + server.close(); + })); + })); +})); diff --git a/test/parallel/test-http2-connect-method-extended.js b/test/parallel/test-http2-connect-method-extended.js new file mode 100644 index 00000000000000..bb424c73f0a2ad --- /dev/null +++ b/test/parallel/test-http2-connect-method-extended.js @@ -0,0 +1,39 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const http2 = require('http2'); + +const settings = { enableConnectProtocol: true }; +const server = http2.createServer({ settings }); +server.on('stream', common.mustCall((stream, headers) => { + assert.strictEqual(headers[':method'], 'CONNECT'); + assert.strictEqual(headers[':scheme'], 'http'); + assert.strictEqual(headers[':protocol'], 'foo'); + assert.strictEqual(headers[':authority'], + `localhost:${server.address().port}`); + assert.strictEqual(headers[':path'], '/'); + stream.respond(); + stream.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + const client = http2.connect(`http://localhost:${server.address().port}`); + client.on('remoteSettings', common.mustCall((settings) => { + assert(settings.enableConnectProtocol); + const req = client.request({ + ':method': 'CONNECT', + ':protocol': 'foo' + }); + req.resume(); + req.on('end', common.mustCall()); + req.on('close', common.mustCall(() => { + assert.strictEqual(req.rstCode, 0); + server.close(); + client.close(); + })); + req.end(); + })); +}));