Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http2: add origin frame support #22956

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/api/errors.md
Expand Up @@ -920,6 +920,11 @@ An invalid HTTP/2 header value was specified.
An invalid HTTP informational status code has been specified. Informational
status codes must be an integer between `100` and `199` (inclusive).

<a id="ERR_HTTP2_INVALID_ORIGIN"></a>
### ERR_HTTP2_INVALID_ORIGIN

HTTP/2 `ORIGIN` frames require a valid origin.

<a id="ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH"></a>
### ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH

Expand Down Expand Up @@ -970,6 +975,11 @@ Nested push streams are not permitted.
An attempt was made to directly manipulate (read, write, pause, resume, etc.) a
socket attached to an `Http2Session`.

<a id="ERR_HTTP2_ORIGIN_LENGTH"></a>
### ERR_HTTP2_ORIGIN_LENGTH

HTTP/2 `ORIGIN` frames are limited to a length of 16382 bytes.

<a id="ERR_HTTP2_OUT_OF_STREAMS"></a>
### ERR_HTTP2_OUT_OF_STREAMS

Expand Down
83 changes: 83 additions & 0 deletions doc/api/http2.md
Expand Up @@ -432,6 +432,8 @@ If the `Http2Session` is connected to a `TLSSocket`, the `originSet` property
will return an `Array` of origins for which the `Http2Session` may be
considered authoritative.

The `originSet` property is only available when using a secure TLS connection.

#### http2session.pendingSettingsAck
<!-- YAML
added: v8.4.0
Expand Down Expand Up @@ -670,6 +672,56 @@ The protocol identifier (`'h2'` in the examples) may be any valid
The syntax of these values is not validated by the Node.js implementation and
are passed through as provided by the user or received from the peer.

#### serverhttp2session.origin(...origins)
<!-- YAML
added: REPLACEME
-->

* `origins` { string | URL | Object } One or more URL Strings passed as
separate arguments.

Submits an `ORIGIN` frame (as defined by [RFC 8336][]) to the connected client
to advertise the set of origins for which the server is capable of providing
authoritative responses.

```js
const http2 = require('http2');
const options = getSecureOptionsSomehow();
const server = http2.createSecureServer(options);
server.on('stream', (stream) => {
stream.respond();
stream.end('ok');
});
server.on('session', (session) => {
session.origin('https://example.com', 'https://example.org');
});
```

When a string is passed as an `origin`, it will be parsed as a URL and the
origin will be derived. For instance, the origin for the HTTP URL
`'https://example.org/foo/bar'` is the ASCII string
`'https://example.org'`. An error will be thrown if either the given string
cannot be parsed as a URL or if a valid origin cannot be derived.

A `URL` object, or any object with an `origin` property, may be passed as
an `origin`, in which case the value of the `origin` property will be
used. The value of the `origin` property *must* be a properly serialized
ASCII origin.

Alternatively, the `origins` option may be used when creating a new HTTP/2
server using the `http2.createSecureServer()` method:

```js
const http2 = require('http2');
const options = getSecureOptionsSomehow();
options.origins = ['https://example.com', 'https://example.org'];
const server = http2.createSecureServer(options);
server.on('stream', (stream) => {
stream.respond();
stream.end('ok');
});
```

### Class: ClientHttp2Session
<!-- YAML
added: v8.4.0
Expand Down Expand Up @@ -700,6 +752,30 @@ client.on('altsvc', (alt, origin, streamId) => {
});
```

#### Event: 'origin'
<!-- YAML
added: REPLACEME
-->

* `origins` {string[]}

The `'origin'` event is emitted whenever an `ORIGIN` frame is received by
the client. The event is emitted with an array of `origin` strings. The
`http2session.originSet` will be updated to include the received
origins.

```js
const http2 = require('http2');
const client = http2.connect('https://example.org');

client.on('origin', (origins) => {
for (let n = 0; n < origins.length; n++)
console.log(origins[n]);
});
```

The `'origin'` event is only emitted when using a secure TLS connection.

#### clienthttp2session.request(headers[, options])
<!-- YAML
added: v8.4.0
Expand Down Expand Up @@ -1914,6 +1990,10 @@ server.listen(80);
<!-- YAML
added: v8.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/22956
description: Added the `origins` option to automatically send an `ORIGIN`
frame on `Http2Session` startup.
- version: v8.9.3
pr-url: https://github.com/nodejs/node/pull/17105
description: Added the `maxOutstandingPings` option with a default limit of
Expand Down Expand Up @@ -1977,6 +2057,8 @@ changes:
remote peer upon connection.
* ...: Any [`tls.createServer()`][] options can be provided. For
servers, the identity options (`pfx` or `key`/`cert`) are usually required.
* `origins` {string[]} An array of origin strings to send within an `ORIGIN`
vsemozhetbyt marked this conversation as resolved.
Show resolved Hide resolved
frame immediately following creation of a new server `Http2Session`.
* `onRequestHandler` {Function} See [Compatibility API][]
* Returns: {Http2SecureServer}

Expand Down Expand Up @@ -3268,6 +3350,7 @@ following additional properties:
[Performance Observer]: perf_hooks.html
[Readable Stream]: stream.html#stream_class_stream_readable
[RFC 7838]: https://tools.ietf.org/html/rfc7838
[RFC 8336]: https://tools.ietf.org/html/rfc8336
[Using `options.selectPadding()`]: #http2_using_options_selectpadding
[`'checkContinue'`]: #http2_event_checkcontinue
[`'request'`]: #http2_event_request
Expand Down
4 changes: 4 additions & 0 deletions lib/internal/errors.js
Expand Up @@ -565,6 +565,8 @@ E('ERR_HTTP2_INVALID_HEADER_VALUE',
'Invalid value "%s" for header "%s"', TypeError);
E('ERR_HTTP2_INVALID_INFO_STATUS',
'Invalid informational status code: %s', RangeError);
E('ERR_HTTP2_INVALID_ORIGIN',
'HTTP/2 ORIGIN frames require a valid origin', TypeError);
E('ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH',
'Packed settings length must be a multiple of six', RangeError);
E('ERR_HTTP2_INVALID_PSEUDOHEADER',
Expand All @@ -580,6 +582,8 @@ E('ERR_HTTP2_NESTED_PUSH',
E('ERR_HTTP2_NO_SOCKET_MANIPULATION',
'HTTP/2 sockets should not be directly manipulated (e.g. read and written)',
Error);
E('ERR_HTTP2_ORIGIN_LENGTH',
'HTTP/2 ORIGIN frames are limited to 16382 bytes', TypeError);
E('ERR_HTTP2_OUT_OF_STREAMS',
'No stream ID is available because maximum stream ID has been reached',
Error);
Expand Down
103 changes: 86 additions & 17 deletions lib/internal/http2/core.js
Expand Up @@ -43,13 +43,15 @@ const {
ERR_HTTP2_HEADERS_AFTER_RESPOND,
ERR_HTTP2_HEADERS_SENT,
ERR_HTTP2_INVALID_INFO_STATUS,
ERR_HTTP2_INVALID_ORIGIN,
ERR_HTTP2_INVALID_PACKED_SETTINGS_LENGTH,
ERR_HTTP2_INVALID_SESSION,
ERR_HTTP2_INVALID_SETTING_VALUE,
ERR_HTTP2_INVALID_STREAM,
ERR_HTTP2_MAX_PENDING_SETTINGS_ACK,
ERR_HTTP2_NESTED_PUSH,
ERR_HTTP2_NO_SOCKET_MANIPULATION,
ERR_HTTP2_ORIGIN_LENGTH,
ERR_HTTP2_OUT_OF_STREAMS,
ERR_HTTP2_PAYLOAD_FORBIDDEN,
ERR_HTTP2_PING_CANCEL,
Expand Down Expand Up @@ -148,6 +150,7 @@ const kInfoHeaders = Symbol('sent-info-headers');
const kLocalSettings = Symbol('local-settings');
const kOptions = Symbol('options');
const kOwner = owner_symbol;
const kOrigin = Symbol('origin');
const kProceed = Symbol('proceed');
const kProtocol = Symbol('protocol');
const kProxySocket = Symbol('proxy-socket');
Expand Down Expand Up @@ -209,6 +212,7 @@ const {
HTTP_STATUS_NO_CONTENT,
HTTP_STATUS_NOT_MODIFIED,
HTTP_STATUS_SWITCHING_PROTOCOLS,
HTTP_STATUS_MISDIRECTED_REQUEST,

STREAM_OPTION_EMPTY_PAYLOAD,
STREAM_OPTION_GET_TRAILERS
Expand Down Expand Up @@ -299,6 +303,11 @@ function onSessionHeaders(handle, id, cat, flags, headers) {
} else {
event = endOfStream ? 'trailers' : 'headers';
}
const session = stream.session;
if (status === HTTP_STATUS_MISDIRECTED_REQUEST) {
const originSet = session[kState].originSet = initOriginSet(session);
originSet.delete(stream[kOrigin]);
}
debug(`Http2Stream ${id} [Http2Session ` +
`${sessionName(type)}]: emitting stream '${event}' event`);
process.nextTick(emit, stream, event, obj, flags, headers);
Expand Down Expand Up @@ -429,6 +438,39 @@ function onAltSvc(stream, origin, alt) {
session.emit('altsvc', alt, origin, stream);
}

function initOriginSet(session) {
let originSet = session[kState].originSet;
if (originSet === undefined) {
const socket = session[kSocket];
session[kState].originSet = originSet = new Set();
if (socket.servername != null) {
let originString = `https://${socket.servername}`;
if (socket.remotePort != null)
originString += `:${socket.remotePort}`;
// We have to ensure that it is a properly serialized
// ASCII origin string. The socket.servername might not
// be properly ASCII encoded.
originSet.add((new URL(originString)).origin);
}
}
return originSet;
}

function onOrigin(origins) {
const session = this[kOwner];
if (session.destroyed)
return;
debug(`Http2Session ${sessionName(session[kType])}: origin received: ` +
`${origins.join(', ')}`);
session[kUpdateTimer]();
if (!session.encrypted || session.destroyed)
return undefined;
const originSet = initOriginSet(session);
for (var n = 0; n < origins.length; n++)
originSet.add(origins[n]);
session.emit('origin', origins);
}

// Receiving a GOAWAY frame from the connected peer is a signal that no
// new streams should be created. If the code === NGHTTP2_NO_ERROR, we
// are going to send our close, but allow existing frames to close
Expand Down Expand Up @@ -782,6 +824,7 @@ function setupHandle(socket, type, options) {
handle.onframeerror = onFrameError;
handle.ongoawaydata = onGoawayData;
handle.onaltsvc = onAltSvc;
handle.onorigin = onOrigin;

if (typeof options.selectPadding === 'function')
handle.ongetpadding = onSelectPadding(options.selectPadding);
Expand All @@ -808,6 +851,12 @@ function setupHandle(socket, type, options) {
options.settings : {};

this.settings(settings);

if (type === NGHTTP2_SESSION_SERVER &&
Array.isArray(options.origins)) {
this.origin(...options.origins);
}

process.nextTick(emit, this, 'connect', this, socket);
}

Expand Down Expand Up @@ -947,23 +996,7 @@ class Http2Session extends EventEmitter {
get originSet() {
if (!this.encrypted || this.destroyed)
return undefined;

let originSet = this[kState].originSet;
if (originSet === undefined) {
const socket = this[kSocket];
this[kState].originSet = originSet = new Set();
if (socket.servername != null) {
let originString = `https://${socket.servername}`;
if (socket.remotePort != null)
originString += `:${socket.remotePort}`;
// We have to ensure that it is a properly serialized
// ASCII origin string. The socket.servername might not
// be properly ASCII encoded.
originSet.add((new URL(originString)).origin);
}
}

return Array.from(originSet);
return Array.from(initOriginSet(this));
}

// True if the Http2Session is still waiting for the socket to connect
Expand Down Expand Up @@ -1338,6 +1371,40 @@ class ServerHttp2Session extends Http2Session {

this[kHandle].altsvc(stream, origin || '', alt);
}

// Submits an origin frame to be sent.
origin(...origins) {
if (this.destroyed)
throw new ERR_HTTP2_INVALID_SESSION();

if (origins.length === 0)
return;

let arr = '';
let len = 0;
const count = origins.length;
for (var i = 0; i < count; i++) {
let origin = origins[i];
if (typeof origin === 'string') {
origin = (new URL(origin)).origin;
} else if (origin != null && typeof origin === 'object') {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a quick question.
How can you be sure here that the object will have the origin attribute?

And an error is thrown below even though this assignment goes through. I just came here by chance and was wondering about it :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the object does not have an origin property, then the origin variable will be assigned as undefined, which will be caught by the following typeof origin !== 'string' check. So, we aren't sure if the object has one, we optimistically assign then check after.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks 👍

origin = origin.origin;
}
if (typeof origin !== 'string')
throw new ERR_INVALID_ARG_TYPE('origin', 'string', origin);
if (origin === 'null')
throw new ERR_HTTP2_INVALID_ORIGIN();

arr += `${origin}\0`;
len += origin.length;
}

if (len > 16382)
throw new ERR_HTTP2_ORIGIN_LENGTH();

this[kHandle].origin(arr, count);
}

}

// ClientHttp2Session instances have to wait for the socket to connect after
Expand Down Expand Up @@ -1406,6 +1473,8 @@ class ClientHttp2Session extends Http2Session {

const stream = new ClientHttp2Stream(this, undefined, undefined, {});
stream[kSentHeaders] = headers;
stream[kOrigin] = `${headers[HTTP2_HEADER_SCHEME]}://` +
`${headers[HTTP2_HEADER_AUTHORITY]}`;

// Close the writable side of the stream if options.endStream is set.
if (options.endStream)
Expand Down
1 change: 1 addition & 0 deletions src/env.h
Expand Up @@ -224,6 +224,7 @@ struct PackageConfig {
V(onnewsession_string, "onnewsession") \
V(onocspresponse_string, "onocspresponse") \
V(ongoawaydata_string, "ongoawaydata") \
V(onorigin_string, "onorigin") \
V(onpriority_string, "onpriority") \
V(onread_string, "onread") \
V(onreadstart_string, "onreadstart") \
Expand Down