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.
- package/README.md +145 -22
- package/index.d.ts +3 -1
- package/package.json +9 -6
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +291 -3
- package/src/VaultClient.js +239 -88
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +142 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/errors.d.ts +5 -0
- package/src/errors.js +18 -0
- package/src/warnOnce.js +44 -0
package/src/VaultApiClient.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
|