node-vault-client 3.1.0 → 3.3.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 CHANGED
@@ -116,6 +116,21 @@ when Vault's `sts_region` / `sts_endpoint` is configured for a non-`us-east-1` r
116
116
  `SignatureDoesNotMatch — Credential should be scoped to a valid region`. Omitting `region`
117
117
  preserves the previous (global-endpoint) behavior.
118
118
 
119
+ #### `iam_server_id_header_value`
120
+
121
+ Set it to the same value as `iam_server_id_header_value` in the mount's `auth/<mount>/config/client`.
122
+ The client then sends it in the signed request as `X-Vault-AWS-IAM-Server-ID`, so the signature
123
+ names your Vault server. A Vault server that enforces its own `iam_server_id_header_value` then
124
+ rejects a request captured from, or meant for, a different server; one that does not set the value
125
+ checks nothing. Vault rejects a login with a missing or wrong value only when the mount sets the
126
+ value. The header does not stop a replay to the same server while the signature is valid (about 15
127
+ minutes).
128
+
129
+ When `config.iam_server_id_header_value` is missing, empty or only whitespace, the first login
130
+ logs one warning and logs in as before, sending the value it has. Each logger gets the warning once
131
+ per Vault server, namespace and mount: clients on the default console logger share it, so the
132
+ process logs it once, and a client with `logger: false` logs nothing and leaves it to the others.
133
+
119
134
  ### AppRole auth
120
135
 
121
136
  ```javascript
@@ -423,6 +438,59 @@ that token expires, and this client does not renew secret leases — only the au
423
438
  dynamic credentials (`database/creds/*`, cloud credentials) whose lease outlives the auth token,
424
439
  leaving renewal on is what currently keeps them alive. KV reads are unaffected.
425
440
 
441
+ #### Tokens revoked on the Vault side
442
+
443
+ A token can stop working before its expiry, for example when an operator revokes it
444
+ (`vault token revoke`, `sys/leases/revoke-prefix`). Vault then answers 403 to every request that
445
+ carries it. When a secret request or a mount detection gets a 403, the client asks
446
+ `auth/token/lookup-self`, which every login already calls, whether Vault still accepts the token:
447
+
448
+ * If it does, the 403 was a policy denial. The call rejects with it as before, and no login is made.
449
+ * If it does not, the client drops the token and its renewal timer, logs `Vault no longer accepts
450
+ the token (accessor=…); logging in again` at `warn`, logs in, and sends the request once more.
451
+ The call then returns what that attempt got: its result, or its error (a failed login, a 404, a
452
+ timeout). Like any re-login, it replays the configured credential (see the table above).
453
+
454
+ Concurrent requests rejected with the same token share one lookup and one login. A 403 on the login
455
+ itself is returned as before. Under `token` auth there is no credential to log in with, so the 403
456
+ is returned as before, without a lookup.
457
+
458
+ So revoking a client's token no longer locks it out: it logs straight back in with the same
459
+ credential. To cut a client off, also revoke or rotate what it logs in with (the AppRole
460
+ `secret_id` or role, the Kubernetes or JWT role binding, the IAM role).
461
+
462
+ #### Revoking the token on close
463
+
464
+ By default [`close()`](#VaultClient+close) only cancels the renewal timer: the token stays
465
+ valid on the Vault server until its TTL runs out. Set `revokeOnClose: true` on the `auth` block
466
+ (beside `type`, like the renewal keys) and `close()` also revokes the held token with
467
+ `auth/token/revoke-self`:
468
+
469
+ ```javascript
470
+ auth: {
471
+ type: 'appRole',
472
+ revokeOnClose: true,
473
+ config: { /* ... */ },
474
+ }
475
+ ```
476
+
477
+ The revocation covers the token held when `close()` runs and the one a login still in flight is
478
+ about to produce — that login still resolves after a close, and its token would otherwise
479
+ outlive the client by its whole TTL. `close()` returns the revocation as a promise, so a caller
480
+ that wants certainty can await it; ignoring the return value is fine too, since the request
481
+ itself keeps the process open until it lands. The promise never rejects: a failed revocation is
482
+ logged (`revokeSelf on close failed`) and the token is left live on the Vault server.
483
+
484
+ Two cautions:
485
+
486
+ * Under `token` auth the token revoked is the one from your configuration, not one the client
487
+ issued for itself. The client warns about this combination at construction; only enable it
488
+ when that token is not shared with anything else.
489
+ * A revoked token cannot be renewed back to life. Calling `close()` and then using the client
490
+ again logs in once more — with backends that cannot mint a fresh credential (`token`), the
491
+ next call fails with Vault's 403 (`VaultHttpError`) from `lookup-self`. A request still in
492
+ flight when the revocation lands surfaces that 403 instead of logging in again.
493
+
426
494
  #### Tuning renewal instead of disabling it
427
495
 
428
496
  Two further keys, also on `auth`, shape *how* renewal happens. Both are optional, and leaving them
@@ -502,9 +570,9 @@ Client constructor function.
502
570
  | --- | --- | --- | --- |
503
571
  | options | `Object` | | |
504
572
  | options.api | <code>Object</code> | | |
505
- | options.api.url | <code>String</code> | | the url of the vault server |
573
+ | options.api.url | <code>String</code> | | the url of the vault server. Plain `http://` to a host other than this machine logs a warning; see "Plain `http://` api.url" |
506
574
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
507
- | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)) |
575
+ | [options.api.requestOptions] | <code>Object</code> | | extra options merged into every HTTP request (see [Custom transport](#custom-transport-proxy--self-signed-tls)); a `redirect` there replaces the same-origin redirect policy (see [Redirects and the Vault token](#redirects-and-the-vault-token)) |
508
576
  | [options.api.timeout] | <code>number</code> | `120000` | milliseconds a request may take, response headers, body and redirects included, before it rejects with `RequestTimeoutError`; `0` turns the limit off, `null` gives the default (see [Request timeouts](#request-timeouts)) |
509
577
  | [options.api.namespace] | <code>String</code> | | Optional. Vault namespace, sent as the `X-Vault-Namespace` header on **every** request — login, token lookup/renewal, and all secret operations — for every auth type. This is the canonical location; `auth.config.namespace` is still honored for backward compatibility. |
510
578
  | [options.api.kv.autoDetect] | <code>boolean</code> | `false` | auto-detect the KV version of each mount on first use (see [KV v2 & generic backends](#kv-v2--generic-backends)) |
@@ -608,17 +676,48 @@ on shutdown. `fetch()` then receives the client's own per-request signal, which
608
676
  shared by every request the client makes, so it works for the first calls and then aborts all
609
677
  later ones. Use `api.timeout` instead.
610
678
 
679
+ ##### Plain `http://` api.url
680
+
681
+ When `api.url` is `http://` and its host is not this machine, the client logs one warning on its
682
+ first request: `"api.url" uses plain http:// for <host:port>, which is not a loopback address; the
683
+ Vault token and secrets travel unencrypted. …` (on port 80 the message names the bare host).
684
+ Nothing else changes: the client connects and works as before. `localhost`, `127.0.0.0/8` (also
685
+ as `::ffff:127.x.y.z`), `::1`, and the unspecified addresses `0.0.0.0` and `::` count as this
686
+ machine; subdomains of `localhost` do not, since the system resolver can send them anywhere. The
687
+ warning names only the host and port, never credentials or a path from the URL. Each logger gets
688
+ it once per host and port: clients on the default console logger share it, so the process logs it
689
+ once, and a client with `logger: false` logs nothing and leaves the warning to the others. If the
690
+ logger throws, the client tries again on its next request. Only `api.url` is checked, not where a
691
+ proxy `dispatcher` or `requestOptions.redirect: 'follow'` sends the request.
692
+
693
+ If the connection is protected another way (a service mesh with mTLS, a TLS-terminating sidecar on
694
+ another host), filter that message in your `logger`, or switch to `https://`.
695
+
611
696
  ##### Redirects and the Vault token
612
697
 
613
- Requests follow HTTP redirects, because Vault HA clusters answer standby nodes with a 307 to the
614
- active node. Node's `fetch()` strips only `Authorization`, `Cookie` and `Proxy-Authorization` when
615
- a redirect crosses origins — `X-Vault-Token` is **not** on that list and is forwarded. A 307 or 308
616
- also re-sends the request body, so a login is replayed to the redirect target with its credentials:
617
- the AppRole `secret_id`, the Kubernetes or JWT token, the signed IAM request and, with
618
- `distributedClaimAccessToken`, a Microsoft Graph access token that is a credential for systems other
619
- than Vault. A host that can answer for `api.url` can therefore redirect the client and receive its
620
- Vault token and its login credentials, so treat `api.url` (and the DNS and any load balancer in
621
- front of it) as trusted infrastructure.
698
+ Requests follow HTTP redirects only within the origin (scheme, host and port) of `api.url`, the
699
+ way `fetch()` follows them: up to 20 hops, a 307 or 308 re-sends the method and body, and a 303,
700
+ or a 301 or 302 after a `POST`, becomes a `GET` without a body. A redirect to any other origin
701
+ rejects with a `VaultError` ("Refusing to follow a redirect from … to …") and nothing is sent
702
+ there. That includes a change of scheme on the same host: if a load balancer redirects an
703
+ `http://` `api.url` to `https://`, use the `https://` URL. Where `fetch()` itself gives up on a
704
+ redirect (more than 20 hops, or a `Location` that does not parse, is not http(s) or carries
705
+ credentials), the request rejects as it always has, with `TypeError: fetch failed` and the reason
706
+ on `cause`, so catch both if you handle redirect failures.
707
+
708
+ Earlier versions followed every redirect. Node's `fetch()` strips only `Authorization`,
709
+ `Cookie` and `Proxy-Authorization` when a redirect crosses origins, so `X-Vault-Token` went to the
710
+ new origin, a 307 or 308 replayed a login body with its credentials (the AppRole `secret_id`, the
711
+ Kubernetes or JWT token, the signed IAM request, a `distributedClaimAccessToken` Graph token), and
712
+ that origin's answer was returned as Vault's.
713
+
714
+ A Vault HA standby forwards requests to the active node. It answers with a 307 to the active
715
+ node's `api_addr` only when forwarding fails or the client sends `X-Vault-No-Request-Forwarding`.
716
+ If that address is another origin, point `api.url` at a load balancer that routes to the active
717
+ node. A `redirect` in `api.requestOptions` (other than `null`, which reads as not set) is passed
718
+ to `fetch()` as it is: `'follow'` restores
719
+ the old behaviour (then treat `api.url`, its DNS and anything in front of it as trusted with the
720
+ token), `'manual'` returns a redirect as a `VaultHttpError`, and `'error'` fails it.
622
721
 
623
722
  <a name="VaultClient+fillNodeConfig"></a>
624
723
 
@@ -807,6 +906,11 @@ console.log(secret);
807
906
  vaultClient.close(); // process can now exit
808
907
  ```
809
908
 
909
+ With `auth.revokeOnClose: true` the held token is also revoked on the Vault server
910
+ (`auth/token/revoke-self`), and `close()` returns the revocation as a never-rejecting promise;
911
+ see [Revoking the token on close](#revoking-the-token-on-close). Ignored, the request still
912
+ completes: it keeps the process open until it lands.
913
+
810
914
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
811
915
 
812
916
 
@@ -815,6 +919,14 @@ discarded rather than arming a fresh timer, so the client stops holding the even
815
919
  same applies when a token is replaced by a new login — a renewal still running against the previous
816
920
  token cannot overwrite the newer one or cancel its timer.
817
921
 
922
+ The same goes for a login that is in flight when `close()` runs, such as the first `read` of a
923
+ client closed before it settles: the read still resolves and the token is cached for later calls,
924
+ but no refresh timer is armed for it. A call made after `close()` that joins that same login
925
+ shares its token and gets no timer either, just as if `close()` had run right after the login. A
926
+ login that starts after `close()` arms one as before. As after any `close()`, the token is then
927
+ used until it expires; under `token` auth, which cannot log in again, later calls reject with
928
+ `AuthTokenExpiredError`, so close a client only once nothing will use it any more.
929
+
818
930
  <a name="Lease"></a>
819
931
 
820
932
  ### Lease
@@ -822,7 +934,7 @@ token cannot overwrite the newer one or cancel its timer.
822
934
  The object returned by `read()` and `list()` (they resolve to `Promise<Lease>`). Use its
823
935
  accessors to extract the secret data:
824
936
 
825
- * `getValue(key)` ⇒ <code>String</code> — value for a single key. Throws `Requested key does not exist` when the key is absent.
937
+ * `getValue(key)` ⇒ <code>\*</code> — value for a single key, as Vault returned it: usually a string, but `null`, `false`, `0`, objects and arrays come back as they are. Throws `Requested key does not exist` when the key is not an own property of the response data (for an array, such as a `list()` result, that means an index or `length`). A name such as `constructor` or `toString` counts only when Vault sent a field by that name, never as an inherited member.
826
938
  * `getData()` ⇒ <code>Object</code> — a deep-cloned copy of the whole secret data object.
827
939
  * `isRenewable()` ⇒ <code>boolean</code> — whether the underlying lease is renewable.
828
940
  * `getMetadata()` ⇒ <code>Object</code> | <code>undefined</code> — KV v2 version metadata
@@ -881,6 +993,18 @@ Clear named Vault instance
881
993
 
882
994
  If no name passed all named instances will be cleared.
883
995
 
996
+ Only a string names one instance. Any other argument (a number, `null`, an object, a symbol)
997
+ clears every instance not named by a symbol, as it always has. That is rarely what was meant:
998
+ `clear(42)`, or `clear` passed as a callback that receives a code or a resolved value
999
+ (`promise.then(VaultClient.clear)`). So the first such call in a process also emits a
1000
+ `DeprecationWarning` with code `NVC_DEP_CLEAR_NON_STRING`, naming the argument's type but not its
1001
+ value. Call `clear()` with no argument to clear every instance.
1002
+
1003
+ Node delivers warnings on the next tick, so the warning is not shown when `clear` runs inside a
1004
+ `process.on('exit')` listener, which still clears every instance. `--no-deprecation` silences the
1005
+ warning. Under `--throw-deprecation` it becomes an uncaught exception on the next tick, not an
1006
+ error thrown by `clear()`.
1007
+
884
1008
  **Kind**: static method of [<code>VaultClient</code>](#VaultClient)
885
1009
 
886
1010
  | Param | Type | Description |
@@ -1094,8 +1218,8 @@ try {
1094
1218
 
1095
1219
  | Class | Extends | When thrown |
1096
1220
  |---|---|---|
1097
- | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided. |
1098
- | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1221
+ | `VaultError` | `Error` | Base class for every error below. Raised directly when mount detection fails (e.g. permission denied) and no `api.engines` override was provided; the failure is on `cause`, so Vault's HTTP status is `err.cause.statusCode`, and a rate-limited detection's `Retry-After` is `err.cause.retryAfter` / `err.cause.retryAfterMs`. Vault answers 403 both for a mount the token may not use and for one that does not exist. |
1222
+ | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. When the response sent a `Retry-After` header (Vault's own rate-limit quotas answer 429 with it, in seconds; a proxy in front of Vault may send it with 429 or 503), it is on `retryAfter` as sent and on `retryAfterMs` in milliseconds: delay-seconds and HTTP-dates are both parsed, the result is capped at 2147483647 (the longest `setTimeout()` delay), and `retryAfterMs` is absent when the value is in neither format. Prefer `retryAfterMs` to parsing `retryAfter` yourself, and clamp it to what your caller can afford to wait. The client does not wait and retry on `Retry-After` itself (token renewal retries do not honour it yet). `message` (`"<status> - <raw body text>"`) and `error` hold the full response body, which can include secret material (e.g. a partially failed transit batch), so log `statusCode` and `error.errors` rather than the error object itself. |
1099
1223
  | `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options or `api.timeout`, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
1100
1224
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
1101
1225
  | `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate and reaches this error at max TTL even with renewal enabled; other backends can also reach it when re-authentication is disabled or their credential can no longer be replayed. |
package/index.d.ts CHANGED
@@ -22,7 +22,7 @@ declare class VaultClient {
22
22
 
23
23
  fillNodeConfig(): Promise<any>;
24
24
  getHeaders(token: VaultClient.AuthToken): Record<string, string>;
25
- close(): void;
25
+ close(): Promise<void> | null | undefined;
26
26
  }
27
27
 
28
28
  declare namespace VaultClient {
@@ -103,6 +103,7 @@ declare namespace VaultClient {
103
103
  renewal?: boolean;
104
104
  renewalFraction?: number;
105
105
  renewalIncrement?: number;
106
+ revokeOnClose?: boolean;
106
107
  }
107
108
 
108
109
  type AuthOptions = AuthOptionsCommon &
@@ -121,7 +122,7 @@ declare namespace VaultClient {
121
122
  }
122
123
 
123
124
  interface Lease {
124
- getValue<T = any>(key: string): T;
125
+ getValue<T = any>(key: string | number): T;
125
126
  getData<T = Record<string, any>>(): T;
126
127
  isRenewable(): boolean;
127
128
  getMetadata(): Record<string, any> | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
5
5
  "repository": {
6
6
  "url": "git+https://github.com/namecheap/node-vault-client.git"
@@ -18,10 +18,10 @@
18
18
  "test": "mocha --exit \"test/**/*.test.mjs\"",
19
19
  "test:unit": "mocha \"test/*.test.mjs\"",
20
20
  "test:types": "tsc -p types/tsconfig.json",
21
- "test:e2e": "mocha --exit \"test/e2e/e2e.test.mjs\"",
22
- "test:e2e:kv2": "mocha --exit \"test/e2e/e2e.kv2.test.mjs\"",
23
- "test:e2e:jwt": "mocha --exit \"test/e2e/e2e.jwt.test.mjs\"",
24
- "test:e2e:generic": "mocha --exit \"test/e2e/e2e.generic.test.mjs\"",
21
+ "test:e2e": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.test.mjs\"",
22
+ "test:e2e:kv2": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.kv2.test.mjs\"",
23
+ "test:e2e:jwt": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.jwt.test.mjs\"",
24
+ "test:e2e:generic": "mocha --exit --require test/e2e/renewal-timer-guard.mjs \"test/e2e/e2e.generic.test.mjs\"",
25
25
  "test:example": "node examples/jwt-auth/vault-jwt-demo.mjs",
26
26
  "coverage": "c8 npm run test:unit",
27
27
  "lint": "eslint src/ test/ examples/",
@@ -65,6 +65,7 @@
65
65
  "@eslint/js": "^10.0.1",
66
66
  "c8": "^12.0.0",
67
67
  "chai": "^6.2.2",
68
+ "config": "^4.4.2",
68
69
  "deep-freeze": "^0.0.1",
69
70
  "eslint": "^10.10.0",
70
71
  "globals": "^17.8.0",
package/src/Lease.js CHANGED
@@ -29,15 +29,21 @@ class Lease {
29
29
  }
30
30
 
31
31
  /**
32
- * @param {String} key
33
- * @returns {String}
32
+ * @param {String|number} key - a field name, or an index when the data is an array (a list)
33
+ * @returns {*} the field's value as Vault returned it: a string for most secrets, but
34
+ * `null`, `false`, `0`, an object or an array are returned as they are
35
+ * @throws {Error} 'Requested key does not exist' unless `key` is an own property of the
36
+ * response data whose value is not `undefined`
34
37
  */
35
38
  getValue(key) {
36
- if (this.__data[key] === undefined) {
39
+ const data = this.__data;
40
+ // Own fields only: an inherited member such as `constructor` or `__proto__` is not
41
+ // something Vault returned.
42
+ if (data === null || typeof data !== 'object' || !Object.hasOwn(data, key) || data[key] === undefined) {
37
43
  throw new Error('Requested key does not exist');
38
44
  }
39
45
 
40
- return this.__data[key];
46
+ return data[key];
41
47
  }
42
48
 
43
49
  /**
@@ -36,6 +36,26 @@ function decodePath(path) {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * The error a caller gets when the detection it started or joined failed. It is built for each
41
+ * caller, so its message names that caller's own path rather than the first caller's. The
42
+ * failure stays on `cause`.
43
+ * safeMessage: this message is logged by the operation's catch handler, so it must not embed a
44
+ * raw response body.
45
+ *
46
+ * @param {Error} err - why the detection failed
47
+ * @param {string} interimKey - the mount the detection was keyed on
48
+ * @param {string} path - the caller's path
49
+ * @returns {VaultError}
50
+ */
51
+ function detectionError(err, interimKey, path) {
52
+ return new VaultError(
53
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
54
+ 'Set api.engines or disable autoDetect to bypass detection.',
55
+ { cause: err }
56
+ );
57
+ }
58
+
39
59
  /**
40
60
  * Resolves the KV engine version for a given secret path.
41
61
  *
@@ -215,6 +235,11 @@ class MountResolver {
215
235
  // The shared detection resolved to a different mount — start our own.
216
236
  // Use the full path as key so it won't collide with the first-segment key.
217
237
  return this.__detectWithCache(path, path);
238
+ }, (err) => {
239
+ // A failed detection is shared, as it always was: one request, whatever it
240
+ // answered, so a rate limit or a failed login is not repeated per caller. Only
241
+ // the error is built per caller, so it names that caller's own path.
242
+ throw detectionError(err, interimKey, path);
218
243
  });
219
244
  }
220
245
 
@@ -255,17 +280,14 @@ class MountResolver {
255
280
  .catch((err) => {
256
281
  // Clear in-flight so callers can retry
257
282
  this.__inflight.delete(interimKey);
258
- // safeMessage: this message is logged by the operation's catch handler, so it
259
- // must not embed a raw response body; the original error stays on `cause`.
260
- throw new VaultError(
261
- `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
262
- 'Set api.engines or disable autoDetect to bypass detection.',
263
- { cause: err }
264
- );
283
+ throw err;
265
284
  });
266
285
 
286
+ // Callers that join get the raw failure and build their own error (see above).
267
287
  this.__inflight.set(interimKey, promise);
268
- return promise;
288
+ return promise.catch((err) => {
289
+ throw detectionError(err, interimKey, path);
290
+ });
269
291
  }
270
292
 
271
293
  /**
@@ -3,6 +3,7 @@
3
3
  const { defaultMaxListeners, getMaxListeners, setMaxListeners } = require('events');
4
4
  const errors = require('./errors');
5
5
  const { trimTrailingSlashes } = require('./slashes');
6
+ const warnOnce = require('./warnOnce');
6
7
 
7
8
  // Above Vault's own default max_request_duration (90 s), so no request a default-configured
8
9
  // Vault would still answer is cut short, and below the 300 s headers/body timeouts of the
@@ -12,6 +13,11 @@ const DEFAULT_TIMEOUT_MS = 120000;
12
13
  const MAX_TIMEOUT_MS = 2147483647;
13
14
  // The listener limit fetch() gives a signal it is passed (see deadline()).
14
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']);
15
21
 
16
22
  function validateTimeout(timeout) {
17
23
  // null reads as "not set", as it does in a config file.
@@ -81,6 +87,87 @@ function deadline(ms, signal) {
81
87
  };
82
88
  }
83
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
+ }
170
+
84
171
  /**
85
172
  * Joins URL segments with single slashes while preserving the leading
86
173
  * protocol (e.g. "https://"). Replaces the former `url-join` dependency.
@@ -101,6 +188,66 @@ function joinUrl(...parts) {
101
188
  .join('/');
102
189
  }
103
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
+
104
251
  class VaultApiClient {
105
252
 
106
253
  /**
@@ -112,7 +259,10 @@ class VaultApiClient {
112
259
  * or for custom TLS trust (self-signed / internal CA). Request-specific fields
113
260
  * (`method`, `body`) always take precedence; `headers` are merged with the
114
261
  * per-request headers winning. Stored by reference (not deep-cloned) so live
115
- * 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).
116
266
  * @param {number} [config.timeout=120000] - milliseconds a request may take, response
117
267
  * headers, body and redirects included, before it rejects with
118
268
  * {@link RequestTimeoutError}. `0` turns the limit off; `null` gives the default.
@@ -142,11 +292,18 @@ class VaultApiClient {
142
292
  logger.warn('"api.timeout" is %d ms; it is in milliseconds, not seconds', this.__timeout);
143
293
  }
144
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
+
145
299
  this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
146
300
  this._logger = logger;
147
301
  }
148
302
 
149
303
  makeRequest(method, path, data, headers) {
304
+ if (this.__plainHttpHost !== null && warnOnPlainHttp(this.__plainHttpHost, this._logger)) {
305
+ this.__plainHttpHost = null;
306
+ }
150
307
  data = data === undefined ? null : data;
151
308
  headers = headers === undefined ? {} : headers;
152
309
 
@@ -160,7 +317,7 @@ class VaultApiClient {
160
317
  const namespaceHeader = this.__namespace ? { 'X-Vault-Namespace': this.__namespace } : {};
161
318
 
162
319
  const options = Object.assign(
163
- { redirect: 'follow' },
320
+ {},
164
321
  requestOptions,
165
322
  {
166
323
  method: method,
@@ -172,6 +329,13 @@ class VaultApiClient {
172
329
  ),
173
330
  }
174
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
+ }
175
339
  if (data !== null) {
176
340
  options.body = JSON.stringify(data);
177
341
  // Only set the default content-type if the caller did not already supply one.
@@ -210,6 +374,10 @@ class VaultApiClient {
210
374
  }
211
375
  }
212
376
 
377
+ if (sameOrigin) {
378
+ pending = pending.then((response) => followSameOrigin(response, uri, options));
379
+ }
380
+
213
381
  const request = pending.then((response) => {
214
382
  return response.text().then((text) => {
215
383
  let body;
@@ -222,7 +390,10 @@ class VaultApiClient {
222
390
  }
223
391
 
224
392
  if (!response.ok) {
225
- throw new errors.VaultHttpError(response.status, text, body);
393
+ // headers?.: fetch test doubles in callers' suites often have no headers
394
+ throw new errors.VaultHttpError(response.status, text, body, {
395
+ retryAfter: response.headers?.get('retry-after'),
396
+ });
226
397
  }
227
398
 
228
399
  // Do not log the response body: Vault responses carry secret