node-vault-client 3.0.2 → 3.2.0

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.
@@ -1,7 +1,172 @@
1
1
  'use strict';
2
2
 
3
+ const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
3
4
  const errors = require('./errors');
4
5
  const { trimTrailingSlashes } = require('./slashes');
6
+ const warnOnce = require('./warnOnce');
7
+
8
+ // Above Vault's own default max_request_duration (90 s), so no request a default-configured
9
+ // Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
10
+ // dispatcher Node's fetch() uses by default.
11
+ const DEFAULT_TIMEOUT_MS = 120000;
12
+ // setTimeout() fires after 1 ms for a delay that does not fit in a signed 32-bit integer.
13
+ const MAX_TIMEOUT_MS = 2147483647;
14
+ // The listener limit fetch() gives a signal it is passed (see deadline()).
15
+ const FETCH_MAX_LISTENERS = 1500;
16
+ // The statuses fetch() follows, its limit on hops, and the headers it drops when a redirect
17
+ // turns the request into a GET without a body (Fetch Standard, "HTTP-redirect fetch").
18
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
19
+ const MAX_REDIRECTS = 20;
20
+ const BODY_HEADERS = new Set(['content-encoding', 'content-language', 'content-location', 'content-type']);
21
+
22
+ function validateTimeout(timeout) {
23
+ // null reads as "not set", as it does in a config file.
24
+ if (timeout === undefined || timeout === null) {
25
+ return DEFAULT_TIMEOUT_MS;
26
+ }
27
+ if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_TIMEOUT_MS) {
28
+ throw new errors.InvalidArgumentsError(
29
+ `"api.timeout" should be an integer number of milliseconds from 0 to ${MAX_TIMEOUT_MS}, ` +
30
+ `got ${String(timeout)}`
31
+ );
32
+ }
33
+ return timeout;
34
+ }
35
+
36
+ /**
37
+ * What fetch() accepts as a signal: Node 20 and 22 take any object with a boolean `aborted`
38
+ * and an `addEventListener` (an abort-controller polyfill, a signal from another realm).
39
+ */
40
+ function isSignalLike(value) {
41
+ return value !== null && typeof value === 'object'
42
+ && typeof value.aborted === 'boolean' && typeof value.addEventListener === 'function';
43
+ }
44
+
45
+ /**
46
+ * A per-request deadline. A plain timer, cleared once the request settles, rather than
47
+ * AbortSignal.timeout(), whose timer cannot be cancelled and so outlives every fast request.
48
+ * A `requestOptions.signal` still aborts the request: it is followed through a listener that
49
+ * is removed at the same point, since that signal lives as long as the client.
50
+ *
51
+ * @param {number} ms
52
+ * @param {AbortSignal} [signal] - `requestOptions.signal`
53
+ * @returns {{signal: AbortSignal, isTimeout: function(*): boolean, clear: function(): void}}
54
+ */
55
+ function deadline(ms, signal) {
56
+ const controller = new AbortController();
57
+ const reason = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
58
+ const timer = setTimeout(() => controller.abort(reason), ms);
59
+ const follow = () => controller.abort(signal.reason);
60
+ if (signal) {
61
+ if (signal.aborted) {
62
+ follow();
63
+ } else {
64
+ // fetch() raised a signal's listener limit to 1500 when it was still at the default;
65
+ // it now gets the client's own signal, so do the same here, or more than ten
66
+ // requests in flight on one signal print MaxListenersExceededWarning (Node 20).
67
+ try {
68
+ if (getMaxListeners(signal) === defaultMaxListeners) {
69
+ setMaxListeners(FETCH_MAX_LISTENERS, signal);
70
+ }
71
+ } catch {
72
+ // not an EventTarget (a polyfill): it has no listener limit to raise
73
+ }
74
+ signal.addEventListener('abort', follow, { once: true });
75
+ }
76
+ }
77
+
78
+ return {
79
+ signal: controller.signal,
80
+ isTimeout: (err) => err === reason,
81
+ clear: () => {
82
+ clearTimeout(timer);
83
+ if (signal && typeof signal.removeEventListener === 'function') {
84
+ signal.removeEventListener('abort', follow);
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ function isPrintableAscii(value) {
91
+ for (let i = 0; i < value.length; i++) {
92
+ const code = value.charCodeAt(i);
93
+ if (code < 0x20 || code > 0x7e) {
94
+ return false;
95
+ }
96
+ }
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Follows the redirects of a request sent with `redirect: 'manual'` as fetch()'s 'follow'
102
+ * would, but only within the origin of the URL it was sent to. 'follow' sends every header
103
+ * but Authorization, Cookie and Proxy-Authorization to any origin a Location names, so
104
+ * X-Vault-Token, and on a 307/308 a login body, would reach that origin, and its answer would
105
+ * be read as Vault's.
106
+ *
107
+ * @param {Response} response - the answer to the request
108
+ * @param {string} url - the URL the request was sent to
109
+ * @param {Object} options - the fetch() options it was sent with
110
+ * @param {number} [hops=0] - redirects followed so far
111
+ * @returns {Response|Promise<Response>} the first answer that is not a redirect
112
+ */
113
+ function followSameOrigin(response, url, options, hops = 0) {
114
+ let location = REDIRECT_STATUSES.has(response.status) ? response.headers.get('location') : null;
115
+ if (location === null) {
116
+ return response;
117
+ }
118
+ // A redirect's own body is never read.
119
+ if (response.body) {
120
+ response.body.cancel().catch(() => {});
121
+ }
122
+
123
+ // Where fetch() gives up on a redirect, it rejects with this; so does this, except for the
124
+ // one thing it adds: refusing another origin.
125
+ const fetchFailed = (cause) => new TypeError('fetch failed', { cause });
126
+ // As fetch() does: a Location sent as raw UTF-8 bytes instead of percent-encoded.
127
+ if (!isPrintableAscii(location)) {
128
+ location = Buffer.from(location, 'latin1').toString('utf8');
129
+ }
130
+ let next;
131
+ try {
132
+ next = new URL(location, url);
133
+ } catch (err) {
134
+ throw fetchFailed(err);
135
+ }
136
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
137
+ throw fetchFailed(new TypeError('URL scheme must be a HTTP(S) scheme'));
138
+ }
139
+ if (hops === MAX_REDIRECTS) {
140
+ throw fetchFailed(new TypeError('redirect count exceeded'));
141
+ }
142
+ const from = new URL(url);
143
+ if (next.origin !== from.origin) {
144
+ throw new errors.VaultError(
145
+ `Refusing to follow a redirect from ${from.origin} to ${next.origin}: ` +
146
+ 'only redirects within the origin of "api.url" are followed'
147
+ );
148
+ }
149
+ if (next.username !== '' || next.password !== '') {
150
+ throw fetchFailed(new TypeError('redirect location includes credentials'));
151
+ }
152
+
153
+ let nextOptions = options;
154
+ const method = options.method.toUpperCase();
155
+ if ((response.status === 303 && method !== 'GET' && method !== 'HEAD')
156
+ || ((response.status === 301 || response.status === 302) && method === 'POST')) {
157
+ const headers = {};
158
+ for (const [name, value] of Object.entries(options.headers)) {
159
+ if (!BODY_HEADERS.has(name.toLowerCase())) {
160
+ headers[name] = value;
161
+ }
162
+ }
163
+ nextOptions = Object.assign({}, options, { method: 'GET', headers });
164
+ delete nextOptions.body;
165
+ }
166
+
167
+ return fetch(next.href, nextOptions)
168
+ .then((nextResponse) => followSameOrigin(nextResponse, next.href, nextOptions, hops + 1));
169
+ }
5
170
 
6
171
  /**
7
172
  * Joins URL segments with single slashes while preserving the leading
@@ -23,6 +188,66 @@ function joinUrl(...parts) {
23
188
  .join('/');
24
189
  }
25
190
 
191
+ /** `::ffff:127.x.y.z` as WHATWG URL prints it: `[::ffff:7fXX:XXXX]`. */
192
+ const IPV4_MAPPED_LOOPBACK = /^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/;
193
+ const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
194
+
195
+ /**
196
+ * True for a host that never leaves the machine: `localhost`, 127.0.0.0/8, its IPv4-mapped IPv6
197
+ * form `::ffff:127.0.0.0/104`, `::1`, and the unspecified addresses `0.0.0.0` and `::`, which
198
+ * connect to this machine. Subdomains of `localhost` are not included: Node resolves them through
199
+ * the system resolver, which can send them anywhere. `hostname` is WHATWG-normalised, so IPv4
200
+ * shorthands such as `127.1` already read `127.0.0.1` and IPv6 is bracketed and compressed.
201
+ * @param {string} hostname - `URL#hostname`
202
+ * @returns {boolean}
203
+ * @private
204
+ */
205
+ function isLoopbackHost(hostname) {
206
+ const host = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
207
+ return host === 'localhost'
208
+ || host === '[::1]'
209
+ || host === '[::]'
210
+ || host === '0.0.0.0'
211
+ || IPV4_MAPPED_LOOPBACK.test(host)
212
+ || IPV4_LOOPBACK.test(host);
213
+ }
214
+
215
+ /**
216
+ * The host:port to warn about when `api.url` is plain http:// to a host off this machine (the
217
+ * token and every secret would cross the network unencrypted), or null. An unparsable URL gives
218
+ * null and is left to fail where it is used, as before.
219
+ * @param {*} url - `api.url`
220
+ * @returns {string|null}
221
+ * @private
222
+ */
223
+ function plainHttpHost(url) {
224
+ try {
225
+ const parsed = new URL(url);
226
+ return parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) ? parsed.host : null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Log the plain http:// warning for `host` unless this `logger.warn` has logged it already.
234
+ * Only the host and port are named, never credentials or a path from the URL.
235
+ * @param {string} host
236
+ * @param {Object} logger
237
+ * @returns {boolean} true once the warning is logged or was logged before; false when the
238
+ * logger threw, so the caller can try again on its next request
239
+ * @private
240
+ */
241
+ function warnOnPlainHttp(host, logger) {
242
+ return warnOnce(
243
+ logger,
244
+ host,
245
+ '"api.url" uses plain http:// for %s, which is not a loopback address; the Vault token and '
246
+ + 'secrets travel unencrypted. Use https:// unless the network path is protected otherwise',
247
+ host
248
+ );
249
+ }
250
+
26
251
  class VaultApiClient {
27
252
 
28
253
  /**
@@ -34,7 +259,13 @@ class VaultApiClient {
34
259
  * or for custom TLS trust (self-signed / internal CA). Request-specific fields
35
260
  * (`method`, `body`) always take precedence; `headers` are merged with the
36
261
  * per-request headers winning. Stored by reference (not deep-cloned) so live
37
- * objects such as a Dispatcher keep their prototype and remain usable.
262
+ * objects such as a Dispatcher keep their prototype and remain usable. Without a
263
+ * `redirect` there, redirects are followed only within the origin of `config.url`;
264
+ * a `redirect` there other than `null` is passed to `fetch()` as it is (`'follow'`
265
+ * follows them to any origin).
266
+ * @param {number} [config.timeout=120000] - milliseconds a request may take, response
267
+ * headers, body and redirects included, before it rejects with
268
+ * {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
38
269
  * @param {Object} logger
39
270
  * @param {String} [namespace] - Vault namespace. When set, `X-Vault-Namespace` is added to
40
271
  * every request (login, token lookup/renewal, and secret operations) so namespacing is
@@ -55,11 +286,24 @@ class VaultApiClient {
55
286
  this.__config.requestOptions = requestOptions;
56
287
  }
57
288
 
289
+ this.__timeout = validateTimeout(this.__config.timeout);
290
+ // 3.0.x ignored an api.timeout key, so a config may still carry one written in seconds.
291
+ if (this.__timeout > 0 && this.__timeout < 1000) {
292
+ logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
293
+ }
294
+
295
+ // Logged on the first request rather than here, so a VaultClient whose construction
296
+ // fails after building this client logs nothing.
297
+ this.__plainHttpHost = plainHttpHost(this.__config.url);
298
+
58
299
  this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
59
300
  this._logger = logger;
60
301
  }
61
302
 
62
303
  makeRequest(method, path, data, headers) {
304
+ if (this.__plainHttpHost !== null && warnOnPlainHttp(this.__plainHttpHost, this._logger)) {
305
+ this.__plainHttpHost = null;
306
+ }
63
307
  data = data === undefined ? null : data;
64
308
  headers = headers === undefined ? {} : headers;
65
309
 
@@ -73,7 +317,7 @@ class VaultApiClient {
73
317
  const namespaceHeader = this.__namespace ? { 'X-Vault-Namespace': this.__namespace } : {};
74
318
 
75
319
  const options = Object.assign(
76
- { redirect: 'follow' },
320
+ {},
77
321
  requestOptions,
78
322
  {
79
323
  method: method,
@@ -85,6 +329,13 @@ class VaultApiClient {
85
329
  ),
86
330
  }
87
331
  );
332
+ // Read after the merge, which copies only own enumerable keys: a `redirect` it leaves
333
+ // out, or an explicit `redirect: undefined`, must not turn fetch()'s 'follow' back on.
334
+ // null reads as "not set", as it does for api.timeout (fetch() rejected it).
335
+ const sameOrigin = options.redirect === undefined || options.redirect === null;
336
+ if (sameOrigin) {
337
+ options.redirect = 'manual';
338
+ }
88
339
  if (data !== null) {
89
340
  options.body = JSON.stringify(data);
90
341
  // Only set the default content-type if the caller did not already supply one.
@@ -102,7 +353,32 @@ class VaultApiClient {
102
353
  uri
103
354
  );
104
355
 
105
- return fetch(uri, options).then((response) => {
356
+ // A requestOptions.signal that fetch() would reject is passed on untouched, without the
357
+ // deadline, so fetch() rejects it as it always has. The deadline is armed last, and
358
+ // cleared if fetch() throws synchronously, so no timer outlives a failed call.
359
+ const userSignal = requestOptions.signal;
360
+ const limit = this.__timeout > 0 && (userSignal === undefined || userSignal === null
361
+ || isSignalLike(userSignal))
362
+ ? deadline(this.__timeout, userSignal || undefined)
363
+ : null;
364
+ let pending;
365
+ if (limit === null) {
366
+ pending = fetch(uri, options);
367
+ } else {
368
+ options.signal = limit.signal;
369
+ try {
370
+ pending = fetch(uri, options);
371
+ } catch (err) {
372
+ limit.clear();
373
+ throw err;
374
+ }
375
+ }
376
+
377
+ if (sameOrigin) {
378
+ pending = pending.then((response) => followSameOrigin(response, uri, options));
379
+ }
380
+
381
+ const request = pending.then((response) => {
106
382
  return response.text().then((text) => {
107
383
  let body;
108
384
  if (text) {
@@ -127,6 +403,18 @@ class VaultApiClient {
127
403
  return body;
128
404
  });
129
405
  });
406
+ if (limit === null) {
407
+ return request;
408
+ }
409
+
410
+ return request
411
+ .catch((err) => {
412
+ if (limit.isTimeout(err)) {
413
+ throw new errors.RequestTimeoutError(this.__timeout, { cause: err });
414
+ }
415
+ throw err;
416
+ })
417
+ .finally(limit.clear);
130
418
  }
131
419
  }
132
420