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 +138 -14
- package/index.d.ts +3 -2
- package/package.json +6 -5
- package/src/Lease.js +10 -4
- package/src/MountResolver.js +30 -8
- package/src/VaultApiClient.js +174 -3
- package/src/VaultClient.js +165 -61
- package/src/auth/AuthToken.js +13 -4
- package/src/auth/VaultBaseAuth.js +256 -9
- package/src/auth/VaultIAMAuth.js +54 -0
- package/src/auth/VaultKubernetesAuth.js +21 -14
- package/src/errors.d.ts +8 -1
- package/src/errors.js +39 -1
- package/src/warnOnce.js +44 -0
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
|
|
614
|
-
|
|
615
|
-
a
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
`
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
46
|
+
return data[key];
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
package/src/MountResolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/VaultApiClient.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|