node-vault-client 2.2.0 → 3.0.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 +163 -8
- package/index.d.ts +7 -1
- package/package.json +12 -12
- package/src/MountResolver.js +90 -11
- package/src/VaultApiClient.js +2 -1
- package/src/VaultClient.js +104 -14
- package/src/VaultNodeConfig.js +21 -1
- package/src/auth/VaultAppRoleAuth.js +3 -2
- package/src/auth/VaultBaseAuth.js +10 -3
- package/src/auth/VaultJwtAuth.js +74 -15
- package/src/kvTransform.js +3 -1
- package/src/safeMessage.js +32 -0
- package/src/slashes.js +32 -0
package/README.md
CHANGED
|
@@ -16,7 +16,11 @@ npm install --save node-vault-client
|
|
|
16
16
|
|
|
17
17
|
### Requirements
|
|
18
18
|
|
|
19
|
-
Node.js >=
|
|
19
|
+
Node.js >= 20.19.0 — the client uses the native `fetch` API. Node 18 support was dropped in 3.0.0
|
|
20
|
+
(Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from the
|
|
21
|
+
Node.js project); see [CHANGELOG.md](CHANGELOG.md) for details.
|
|
22
|
+
|
|
23
|
+
Upgrading from 2.x? [MIGRATION.md](MIGRATION.md) lists what can need a change and what to check.
|
|
20
24
|
|
|
21
25
|
### TypeScript
|
|
22
26
|
|
|
@@ -36,7 +40,8 @@ const password = lease.getValue<string>('password');
|
|
|
36
40
|
|
|
37
41
|
`auth` is a discriminated union on `type`, so each backend only accepts its own
|
|
38
42
|
configuration, and the three mutually-exclusive JWT sources (`jwt`, `jwtPath`,
|
|
39
|
-
`jwtProvider`) are enforced at compile time
|
|
43
|
+
`jwtProvider`) are enforced at compile time — as is the mutually-exclusive pair
|
|
44
|
+
`distributedClaimAccessToken` / `distributedClaimAccessTokenProvider`.
|
|
40
45
|
|
|
41
46
|
Types that appear in signatures are exported in type space under the `VaultClient`
|
|
42
47
|
namespace — `VaultClient.Lease`, `VaultClient.VaultOptions`, `VaultClient.AuthToken`
|
|
@@ -167,8 +172,9 @@ const vaultClient = VaultClient.boot('main', {
|
|
|
167
172
|
type: 'jwt',
|
|
168
173
|
mount: 'jwt', // Optional. Vault JWT auth mount point ("jwt" by default)
|
|
169
174
|
config: {
|
|
170
|
-
role: 'my-app',
|
|
171
|
-
jwt: process.env.CI_JOB_JWT,
|
|
175
|
+
role: 'my-app', // Optional. Role configured in Vault's JWT auth backend; omitted uses the mount's `default_role`
|
|
176
|
+
jwt: process.env.CI_JOB_JWT, // Exactly one of `jwt` / `jwtPath` / `jwtProvider` is required (see below)
|
|
177
|
+
distributedClaimAccessToken: process.env.GRAPH_TOKEN, // Optional. Azure/Entra ID group lookups only (see below); or `distributedClaimAccessTokenProvider`
|
|
172
178
|
},
|
|
173
179
|
},
|
|
174
180
|
});
|
|
@@ -191,6 +197,96 @@ Exactly one of three mutually exclusive `config` keys supplies the JWT:
|
|
|
191
197
|
be minted per login — GitHub Actions' `core.getIDToken()`, a cloud metadata endpoint, a
|
|
192
198
|
SPIFFE/SPIRE workload API.
|
|
193
199
|
|
|
200
|
+
##### The Vault role needs `bound_audiences` when your JWT carries an `aud` claim
|
|
201
|
+
|
|
202
|
+
This is server-side configuration rather than a client option, but it is the most common reason a
|
|
203
|
+
first login fails. Vault requires a `jwt` role to bind the audience your token carries, and it does
|
|
204
|
+
**not** catch the omission when the role is created — as long as the role has some other bound
|
|
205
|
+
constraint (`bound_subject`, `bound_claims`, ...) it is accepted, and the problem only surfaces at
|
|
206
|
+
login:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
VaultHttpError: 400 - {"errors":["audience claim found in JWT but no audiences bound to the role"]}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A wrong (rather than missing) audience fails with `error validating token: invalid audience (aud)
|
|
213
|
+
claim: audience claim does not match any expected audience`. So bind the audience the token
|
|
214
|
+
actually has:
|
|
215
|
+
|
|
216
|
+
```shell
|
|
217
|
+
vault write auth/jwt/role/my-app \
|
|
218
|
+
role_type=jwt user_claim=sub bound_audiences=my-audience token_policies=my-policy
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A role with *no* bound constraint at all is rejected when you create it (`must have at least one
|
|
222
|
+
bound constraint when creating/updating a role`), so that case is self-correcting. An `aud` array
|
|
223
|
+
is fine — Vault matches `bound_audiences` against any entry.
|
|
224
|
+
|
|
225
|
+
Vault also does not require an `exp` claim: a token minted without one is accepted and never
|
|
226
|
+
expires. If you write your own `jwtProvider`, give the tokens it mints a short `exp`.
|
|
227
|
+
|
|
228
|
+
##### Azure / Entra ID group lookups need `distributedClaimAccessToken`
|
|
229
|
+
|
|
230
|
+
Skip this unless you log in with Microsoft Entra ID (Azure AD) tokens *and* the Vault role sets
|
|
231
|
+
`groups_claim`. Vault's `distributed_claim_access_token` parameter
|
|
232
|
+
["only applies to the Azure (Entra ID) provider"](https://developer.hashicorp.com/vault/api-docs/auth/jwt#distributed_claim_access_token);
|
|
233
|
+
on every other IdP these two keys do nothing at all.
|
|
234
|
+
|
|
235
|
+
Azure does not always put group membership in the token. Past 200 groups it sends OIDC
|
|
236
|
+
[distributed claims](https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims)
|
|
237
|
+
instead — `_claim_names`/`_claim_sources` pointing at the Microsoft Graph API rather than the group
|
|
238
|
+
names themselves — and a mount configured with `fetch_groups` skips the claim entirely and always
|
|
239
|
+
asks Graph. Either way Vault has to call Graph itself, and the JWT you logged in with is not a
|
|
240
|
+
credential for that call: it needs a separate Graph access token, sent on the login request as the
|
|
241
|
+
optional `distributed_claim_access_token`. Leave it out on a mount/role set up this way and the
|
|
242
|
+
login fails at the group-fetch step — *after* the JWT has already validated, so the error names the
|
|
243
|
+
group lookup rather than anything about your token.
|
|
244
|
+
|
|
245
|
+
Two optional, mutually exclusive `config` keys supply it. Passing both raises
|
|
246
|
+
`InvalidArgumentsError` at construction; passing neither leaves the login request byte-for-byte
|
|
247
|
+
what it was before these keys existed.
|
|
248
|
+
|
|
249
|
+
* **`distributedClaimAccessToken`** — a literal Graph access token. It carries exactly the caveat
|
|
250
|
+
the literal `jwt` above does, for the same reason: the value is fixed at construction and every
|
|
251
|
+
re-login re-sends it, so it works until that token expires. Entra access tokens last about an
|
|
252
|
+
hour, which is shorter than most processes — fine for a one-shot script, wrong for a service.
|
|
253
|
+
* **`distributedClaimAccessTokenProvider`** — an (optionally async) function, called fresh at login
|
|
254
|
+
time (never at construction, never cached), returning `string | Promise<string>`. Acquire the
|
|
255
|
+
Graph token inside it — an MSAL client-credentials call, the Azure IMDS managed-identity endpoint
|
|
256
|
+
— and every login gets a current one. Resolving to a non-string or an empty string raises
|
|
257
|
+
`InvalidArgumentsError` without sending a login request.
|
|
258
|
+
|
|
259
|
+
```javascript
|
|
260
|
+
const vaultClient = VaultClient.boot('main', {
|
|
261
|
+
api: { url: 'https://vault.example.com:8200/' },
|
|
262
|
+
auth: {
|
|
263
|
+
type: 'jwt',
|
|
264
|
+
config: {
|
|
265
|
+
role: 'my-app',
|
|
266
|
+
jwtProvider: () => getEntraIdToken(),
|
|
267
|
+
distributedClaimAccessTokenProvider: () => getGraphAccessToken(),
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
On the Vault side, `fetch_groups` lives in `provider_config` on the **mount's config, not on the
|
|
274
|
+
role** — which is where people tend to look for it:
|
|
275
|
+
|
|
276
|
+
```shell
|
|
277
|
+
vault write auth/jwt/config \
|
|
278
|
+
oidc_discovery_url=https://login.microsoftonline.com/<tenant-id>/v2.0 \
|
|
279
|
+
provider_config='{"provider":"azure","fetch_groups":true}'
|
|
280
|
+
|
|
281
|
+
vault write auth/jwt/role/my-app \
|
|
282
|
+
role_type=jwt user_claim=sub bound_audiences=<application-client-id> \
|
|
283
|
+
groups_claim=groups token_policies=my-policy
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`fetch_groups` is optional — the distributed-claim path is taken without it whenever Azure omits
|
|
287
|
+
the groups claim. `groups_claim` on the role is not: with it unset Vault never resolves groups at
|
|
288
|
+
all, so a Graph token you pass is accepted and never used.
|
|
289
|
+
|
|
194
290
|
#### Authenticating from GitHub Actions
|
|
195
291
|
|
|
196
292
|
```yaml
|
|
@@ -212,6 +308,18 @@ const vaultClient = VaultClient.boot('ci', {
|
|
|
212
308
|
});
|
|
213
309
|
```
|
|
214
310
|
|
|
311
|
+
`core.getIDToken('vault')` mints a token whose `aud` is `vault`, so the role has to bind that
|
|
312
|
+
audience or the login fails with `audience claim found in JWT but no audiences bound to the role`:
|
|
313
|
+
|
|
314
|
+
```shell
|
|
315
|
+
vault write auth/gha/role/ci \
|
|
316
|
+
role_type=jwt user_claim=sub bound_audiences=vault \
|
|
317
|
+
bound_claims='{"repository":"my-org/my-repo"}' token_policies=ci
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Pass the same string to `core.getIDToken()` and to `bound_audiences`. Calling `getIDToken()` with
|
|
321
|
+
no argument uses GitHub's default audience instead, which then will not match.
|
|
322
|
+
|
|
215
323
|
`role` is optional here too — omit it to use the mount's `default_role`. `mount` and
|
|
216
324
|
`api.namespace` behave exactly as they do for the other four backends.
|
|
217
325
|
|
|
@@ -367,7 +475,7 @@ Client constructor function.
|
|
|
367
475
|
| [options.auth.renewal] | <code>boolean</code> | <code>true</code> | Set `false` to never renew the Vault token in the background; the token is used until it expires and the next call re-authenticates. See [Token renewal](#token-renewal). |
|
|
368
476
|
| [options.auth.renewalFraction] | <code>number</code> | <code>0.5</code> | How much of the token's remaining lifetime to wait before renewing, as a fraction in `(0, 1)`. |
|
|
369
477
|
| [options.auth.renewalIncrement] | <code>number</code> | | Seconds of extra TTL to request on each renewal, sent as `increment` to `auth/token/renew-self`. Vault caps it at the token's max TTL. |
|
|
370
|
-
| [options.logger] | <code>Object</code> \| <code>false</code> | | Logger that must implement **all five** of "error", "warn", "info", "debug" and "trace" — an object missing any one of them is silently ignored and the default logger is used instead. The default logger writes to `console`, except `debug`, which is discarded
|
|
478
|
+
| [options.logger] | <code>Object</code> \| <code>false</code> | | Logger that must implement **all five** of "error", "warn", "info", "debug" and "trace" — an object missing any one of them is silently ignored and the default logger is used instead. The default logger writes to `console`, except `debug`, which is discarded. Failure logs (`error` level) never include a Vault response body: only the HTTP status and Vault's `errors` strings, which can quote back malformed request fields. Pass `false` to disable logging entirely. |
|
|
371
479
|
|
|
372
480
|
##### Custom transport (proxy / self-signed TLS)
|
|
373
481
|
|
|
@@ -524,7 +632,8 @@ Raw request — escape hatch for any Vault backend
|
|
|
524
632
|
|
|
525
633
|
Sends the literal API path with no KV path rewriting and no response unwrapping, and
|
|
526
634
|
resolves to the parsed response body. Use it for non-KV backends (e.g. Transit) or when you
|
|
527
|
-
have already constructed the complete Vault API path.
|
|
635
|
+
have already constructed the complete Vault API path. The path is **not validated** (see
|
|
636
|
+
[Path validation](#path-validation)), so never build it from untrusted input.
|
|
528
637
|
|
|
529
638
|
**Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
|
|
530
639
|
|
|
@@ -796,6 +905,11 @@ const result = await client.request('POST', 'transit/encrypt/my-key', {
|
|
|
796
905
|
console.log(result.data.ciphertext);
|
|
797
906
|
```
|
|
798
907
|
|
|
908
|
+
Transit batch encrypt/decrypt answers HTTP 400 when any item fails, and the rejection's
|
|
909
|
+
`err.error.data.batch_results` still holds the successful items. Pass
|
|
910
|
+
`partial_failure_response_code: 200` (Vault 1.12+) and check each item's `error` if you don't want
|
|
911
|
+
that data on an error path.
|
|
912
|
+
|
|
799
913
|
### Lease.getMetadata()
|
|
800
914
|
|
|
801
915
|
`getMetadata()` is additive — existing code is unaffected.
|
|
@@ -831,10 +945,51 @@ rewriting:
|
|
|
831
945
|
await client.request('GET', 'secret/data/foo');
|
|
832
946
|
```
|
|
833
947
|
|
|
948
|
+
### Path validation
|
|
949
|
+
|
|
950
|
+
The KV methods (`read`, `list`, `write`, `delete`, `update` and the KV v2 helpers) reject a
|
|
951
|
+
path with `InvalidArgumentsError`, before any request is sent, when the URL parser or Vault
|
|
952
|
+
would resolve it to a different path than the one passed:
|
|
953
|
+
|
|
954
|
+
- an empty path;
|
|
955
|
+
- `?`, `#`, `\`, a control character (U+0000–U+001F, U+007F), an unpaired surrogate or a
|
|
956
|
+
trailing space;
|
|
957
|
+
- a percent-encoded `.`, `/`, `\` or `%` (`%2e`, `%2f`, `%5c`, `%25`, in any case). Vault decodes
|
|
958
|
+
these and cleans the path again on every redirect hop, so they are rejected rather than decoded;
|
|
959
|
+
- an empty segment such as `a//b` or `a//`, which Vault collapses into the parent path, so
|
|
960
|
+
`tenants/${id}/key` with an empty `id` would address `tenants/key`;
|
|
961
|
+
- a `.` or `..` segment;
|
|
962
|
+
- a percent-encoded mount (`secre%74/foo`): the `engines` override and mount detection match the
|
|
963
|
+
path as written, but Vault decodes it, so `secre%74/config` would skip the KV v2 rewrite and
|
|
964
|
+
reach the mount's own `config` endpoint;
|
|
965
|
+
- a trailing slash on the KV v2 helpers (`deleteVersions`, `undeleteVersions`,
|
|
966
|
+
`destroyVersions`, `readMetadata`, `deleteMetadata`): Vault applies a metadata call on
|
|
967
|
+
`tenants/me/` to the key `tenants/me`.
|
|
968
|
+
|
|
969
|
+
Leading slashes are dropped before the mount is resolved, so `/secret/foo` and `secret/foo`
|
|
970
|
+
address the same key (an `engines` key may be written either way). The other methods keep a
|
|
971
|
+
single trailing slash, as in `list('secret/apps/')`. A percent-encoded segment of a
|
|
972
|
+
multi-segment mount (`team/k%76/foo` for the mount `team/kv`) is rejected too: by the `engines`
|
|
973
|
+
lookup, or after detection with `autoDetect`.
|
|
974
|
+
|
|
975
|
+
A key name that needs `?` or `#` can still be addressed percent-encoded (`%3F`, `%23`); Vault
|
|
976
|
+
decodes it. A key that needs any other rejected character goes through `request()`, as does a
|
|
977
|
+
specific KV v2 version, which used to work as `read('secret/foo?version=2')`:
|
|
978
|
+
`request('GET', 'secret/data/foo?version=2')`. These checks apply in every mode (passthrough,
|
|
979
|
+
`engines`, `autoDetect`). `request()` sends its path literally and does not apply them.
|
|
980
|
+
|
|
981
|
+
Still validate lower-trust input before building a path from it: these checks only stop a path
|
|
982
|
+
from reaching a different Vault path than the one it names, not a caller from naming another
|
|
983
|
+
tenant's secret.
|
|
984
|
+
|
|
985
|
+
A validation error's message says which rule failed but does not include the path, because the
|
|
986
|
+
KV methods log the message at `error` level.
|
|
987
|
+
|
|
834
988
|
### Mount detection caching
|
|
835
989
|
|
|
836
990
|
- Each canonical mount is detected once and then cached for the life of the `VaultClient` instance. The cache is a bounded LRU with a fixed cap of 500 mounts (not configurable through client options); a long-lived client that touches more than 500 distinct mounts evicts the least-recently-used entries, and an evicted mount is detected again on its next use.
|
|
837
991
|
- Concurrent first-touch requests for the same mount share a single in-flight detection promise.
|
|
992
|
+
- A detection response without a string `path` and `type` fails the call with `VaultError` and nothing is cached. A well-formed answer is cached only when its mount contains the requested path; when it does not, that caller gets `InvalidArgumentsError` and callers sharing the detection are unaffected. Auth mounts are cached as `auth/<mount>`, so they never collide with a secrets mount of the same name, and a mount in a child namespace is keyed relative to `api.namespace`.
|
|
838
993
|
- The detection endpoint used is `GET sys/internal/ui/mounts/<path>` (readable by any authenticated token).
|
|
839
994
|
- When the token lacks permission on that endpoint, set `api.engines` to skip detection.
|
|
840
995
|
|
|
@@ -860,8 +1015,8 @@ try {
|
|
|
860
1015
|
| Class | Extends | When thrown |
|
|
861
1016
|
|---|---|---|
|
|
862
1017
|
| `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. |
|
|
863
|
-
| `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. |
|
|
864
|
-
| `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options,
|
|
1018
|
+
| `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. |
|
|
1019
|
+
| `InvalidArgumentsError` | `VaultError` | Bad arguments or configuration: `boot()` called without options, an unknown instance name passed to `get()`, an unsupported `auth.type`, invalid renewal options, an invalid node-config substitution map, or a KV method path that fails [path validation](#path-validation). |
|
|
865
1020
|
| `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
|
|
866
1021
|
| `AuthTokenExpiredError` | `VaultError` | The Vault token expired and the backend cannot obtain a new one. `token` auth can never re-authenticate; other backends reach this only with `renewal: false` and a credential they cannot replay. |
|
|
867
1022
|
| `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
|
package/index.d.ts
CHANGED
|
@@ -79,12 +79,18 @@ declare namespace VaultClient {
|
|
|
79
79
|
namespace?: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
type JwtDistributedClaimConfig =
|
|
83
|
+
| { distributedClaimAccessToken?: never; distributedClaimAccessTokenProvider?: never }
|
|
84
|
+
| { distributedClaimAccessToken: string; distributedClaimAccessTokenProvider?: never }
|
|
85
|
+
| { distributedClaimAccessTokenProvider: () => string | Promise<string>; distributedClaimAccessToken?: never };
|
|
86
|
+
|
|
82
87
|
type JwtAuthConfig = JwtAuthConfigCommon &
|
|
83
88
|
(
|
|
84
89
|
| { jwt: string; jwtPath?: never; jwtProvider?: never }
|
|
85
90
|
| { jwtPath: string; jwt?: never; jwtProvider?: never }
|
|
86
91
|
| { jwtProvider: () => string | Promise<string>; jwt?: never; jwtPath?: never }
|
|
87
|
-
)
|
|
92
|
+
) &
|
|
93
|
+
JwtDistributedClaimConfig;
|
|
88
94
|
|
|
89
95
|
interface AuthOptionsCommon {
|
|
90
96
|
mount?: string;
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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"
|
|
7
7
|
},
|
|
8
8
|
"engines": {
|
|
9
|
-
"node": ">=
|
|
9
|
+
"node": ">=20.19.0"
|
|
10
10
|
},
|
|
11
11
|
"main": "src/VaultClient.js",
|
|
12
12
|
"types": "index.d.ts",
|
|
@@ -45,31 +45,31 @@
|
|
|
45
45
|
"author": "Namecheap <opensource@namecheap.com>",
|
|
46
46
|
"license": "Apache-2.0",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@aws-sdk/credential-providers": "^3.
|
|
48
|
+
"@aws-sdk/credential-providers": "^3.1130.0",
|
|
49
49
|
"aws4": "^1.13.2",
|
|
50
50
|
"long-timeout": "^0.1.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"config": ">=1 <
|
|
53
|
+
"config": ">=1 <5"
|
|
54
54
|
},
|
|
55
55
|
"overrides": {
|
|
56
|
-
"serialize-javascript": "^7.
|
|
56
|
+
"serialize-javascript": "^7.1.4",
|
|
57
57
|
"glob": "^13.0.0",
|
|
58
58
|
"diff": "^8.0.3",
|
|
59
|
-
"brace-expansion": "^5.0.
|
|
60
|
-
"js-yaml": "^4.3.
|
|
59
|
+
"brace-expansion": "^5.0.12",
|
|
60
|
+
"js-yaml": "^4.3.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@eslint/js": "^
|
|
64
|
-
"c8": "^
|
|
63
|
+
"@eslint/js": "^10.0.1",
|
|
64
|
+
"c8": "^12.0.0",
|
|
65
65
|
"chai": "^6.2.2",
|
|
66
66
|
"deep-freeze": "^0.0.1",
|
|
67
|
-
"eslint": "^
|
|
67
|
+
"eslint": "^10.10.0",
|
|
68
68
|
"globals": "^17.8.0",
|
|
69
69
|
"lodash": "^4.18.1",
|
|
70
|
-
"mocha": "^
|
|
70
|
+
"mocha": "^12.0.0",
|
|
71
71
|
"sinon": "^22.1.0",
|
|
72
72
|
"sinon-chai": "^4.0.1",
|
|
73
|
-
"typescript": "^
|
|
73
|
+
"typescript": "^7.0.2"
|
|
74
74
|
}
|
|
75
75
|
}
|
package/src/MountResolver.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { VaultError, InvalidArgumentsError } = require('./errors');
|
|
4
|
+
const safeMessage = require('./safeMessage');
|
|
5
|
+
const { trimTrailingSlashes, trimSlashes } = require('./slashes');
|
|
4
6
|
|
|
5
7
|
// Default cap for the mount cache. Real deployments talk to a handful of static
|
|
6
8
|
// mounts, so this is generous headroom; the cap exists so that a long-lived
|
|
@@ -8,6 +10,32 @@ const { VaultError, InvalidArgumentsError } = require('./errors');
|
|
|
8
10
|
// cache — and the per-resolve lookup cost — without bound (issue #108).
|
|
9
11
|
const DEFAULT_MAX_CACHE_SIZE = 500;
|
|
10
12
|
|
|
13
|
+
/** True when `mount` is `path` itself or a whole-segment prefix of it. */
|
|
14
|
+
function containsPath(mount, path) {
|
|
15
|
+
return mount !== '' && (path === mount || path.startsWith(mount + '/'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per GHSA-rr65/GHSA-wf8v a mount must be named literally: Vault decodes the path, so
|
|
20
|
+
* 'team/k%76/x' would reach the 'team/kv' mount while matching it neither in the engines map
|
|
21
|
+
* nor as a prefix, and the KV v2 rewrite would be skipped or built on the wrong mount.
|
|
22
|
+
*/
|
|
23
|
+
function encodedMountError(mount) {
|
|
24
|
+
return new InvalidArgumentsError(
|
|
25
|
+
`Invalid secret path: it does not start with the mount "${mount}" it resolved to; ` +
|
|
26
|
+
'do not percent-encode the mount'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// '%2f' and '%25' never get here (VaultClient rejects them), so one decode is what Vault sees.
|
|
31
|
+
function decodePath(path) {
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(path);
|
|
34
|
+
} catch {
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
/**
|
|
12
40
|
* Resolves the KV engine version for a given secret path.
|
|
13
41
|
*
|
|
@@ -23,10 +51,15 @@ const DEFAULT_MAX_CACHE_SIZE = 500;
|
|
|
23
51
|
* @param {Object} [opts] - additional options
|
|
24
52
|
* @param {boolean} [opts.disabled] - if true, always return passthrough (version 1)
|
|
25
53
|
* @param {number} [opts.maxCacheSize] - LRU cap for the mount cache (default 500)
|
|
54
|
+
* @param {string} [opts.namespace] - the client's Vault namespace (X-Vault-Namespace)
|
|
26
55
|
*/
|
|
27
56
|
class MountResolver {
|
|
28
57
|
constructor(detectFn, enginesOverride, logger, opts) {
|
|
29
58
|
this.__detectFn = detectFn;
|
|
59
|
+
// Vault reports a mount in a child namespace with its full namespace path
|
|
60
|
+
// ("ns1/ns2/secret/"), while the caller's path is relative to this namespace.
|
|
61
|
+
const namespace = opts && typeof opts.namespace === 'string' ? trimSlashes(opts.namespace) : '';
|
|
62
|
+
this.__namespacePrefix = namespace ? `${namespace}/` : '';
|
|
30
63
|
this.__log = logger;
|
|
31
64
|
this.__disabled = opts && opts.disabled === true;
|
|
32
65
|
// Validate before use: a non-positive cap would make the eviction loop in
|
|
@@ -43,10 +76,11 @@ class MountResolver {
|
|
|
43
76
|
|
|
44
77
|
// The engines map is immutable after construction: normalize and sort the
|
|
45
78
|
// entries once (longest raw prefix first, matching the previous per-call
|
|
46
|
-
// sort) instead of re-sorting on every resolve().
|
|
79
|
+
// sort) instead of re-sorting on every resolve(). Leading slashes go too:
|
|
80
|
+
// VaultClient drops them from the path before it gets here.
|
|
47
81
|
this.__engines = Object.entries(enginesOverride || {})
|
|
48
82
|
.sort((a, b) => b[0].length - a[0].length)
|
|
49
|
-
.map(([prefix, version]) => [prefix
|
|
83
|
+
.map(([prefix, version]) => [trimSlashes(prefix), Number(version)]);
|
|
50
84
|
|
|
51
85
|
// LRU cache: canonical mount path (no trailing slash) -> { mount, version, type }.
|
|
52
86
|
// Map iteration order is insertion order; hits are re-inserted to refresh
|
|
@@ -66,6 +100,10 @@ class MountResolver {
|
|
|
66
100
|
// 1. Check engines override (longest-prefix match). This applies even when
|
|
67
101
|
// auto-detection is disabled, so listed mounts resolve with no I/O.
|
|
68
102
|
const override = this.__enginesOverrideLookup(path);
|
|
103
|
+
const decodedOverride = this.__enginesOverrideLookup(decodePath(path));
|
|
104
|
+
if (decodedOverride !== null && (override === null || override.mount !== decodedOverride.mount)) {
|
|
105
|
+
return Promise.reject(encodedMountError(decodedOverride.mount));
|
|
106
|
+
}
|
|
69
107
|
if (override !== null) {
|
|
70
108
|
this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
|
|
71
109
|
return Promise.resolve(override);
|
|
@@ -86,8 +124,15 @@ class MountResolver {
|
|
|
86
124
|
// if it does not (two distinct multi-segment mounts share a first segment,
|
|
87
125
|
// e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
|
|
88
126
|
// full path as key so each mount gets its own call.
|
|
127
|
+
// Each caller checks the result against its own path, so one caller's bad path
|
|
128
|
+
// cannot fail others sharing the detection.
|
|
89
129
|
const interimKey = path.split('/')[0];
|
|
90
|
-
return this.__detectWithCache(interimKey, path)
|
|
130
|
+
return this.__detectWithCache(interimKey, path).then((entry) => {
|
|
131
|
+
if (!containsPath(entry.mount, path)) {
|
|
132
|
+
throw encodedMountError(entry.mount);
|
|
133
|
+
}
|
|
134
|
+
return entry;
|
|
135
|
+
});
|
|
91
136
|
}
|
|
92
137
|
|
|
93
138
|
// -------------------------------------------------------------------------
|
|
@@ -101,7 +146,7 @@ class MountResolver {
|
|
|
101
146
|
*/
|
|
102
147
|
__enginesOverrideLookup(path) {
|
|
103
148
|
for (const [prefix, version] of this.__engines) {
|
|
104
|
-
if (
|
|
149
|
+
if (containsPath(prefix, path)) {
|
|
105
150
|
return { mount: prefix, version, type: 'kv' };
|
|
106
151
|
}
|
|
107
152
|
}
|
|
@@ -164,7 +209,7 @@ class MountResolver {
|
|
|
164
209
|
// Join the existing in-flight promise but verify the result matches our path.
|
|
165
210
|
// If the detection was for a different sub-mount, start a fresh detection.
|
|
166
211
|
return this.__inflight.get(interimKey).then((entry) => {
|
|
167
|
-
if (
|
|
212
|
+
if (containsPath(entry.mount, path)) {
|
|
168
213
|
return entry;
|
|
169
214
|
}
|
|
170
215
|
// The shared detection resolved to a different mount — start our own.
|
|
@@ -181,8 +226,16 @@ class MountResolver {
|
|
|
181
226
|
throw new Error('Unexpected empty detection response');
|
|
182
227
|
}
|
|
183
228
|
|
|
184
|
-
|
|
185
|
-
|
|
229
|
+
// Require a mounts-endpoint shape (string path and type) and never fall back
|
|
230
|
+
// to a caller-derived key or a default type. This does not authenticate the
|
|
231
|
+
// response: a planted secret could carry the same fields. What keeps the
|
|
232
|
+
// detection request on the mounts endpoint is the path validation in
|
|
233
|
+
// VaultClient; this only stops a malformed answer from becoming routing.
|
|
234
|
+
if (typeof info.path !== 'string' || typeof info.type !== 'string' || !trimTrailingSlashes(info.path)) {
|
|
235
|
+
throw new Error('Unexpected detection response shape (missing string path/type)');
|
|
236
|
+
}
|
|
237
|
+
const canonicalMount = this.__canonicalMount(info.path, path);
|
|
238
|
+
const type = info.type;
|
|
186
239
|
const optVersion = info.options && info.options.version;
|
|
187
240
|
// Only kv v2 gets special treatment; everything else is passthrough
|
|
188
241
|
const version = (type === 'kv' && optVersion === '2') ? 2 : 1;
|
|
@@ -190,8 +243,11 @@ class MountResolver {
|
|
|
190
243
|
const entry = { mount: canonicalMount, version, type };
|
|
191
244
|
this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
|
|
192
245
|
|
|
193
|
-
//
|
|
194
|
-
|
|
246
|
+
// Cache only an answer that contains the path it was asked for; resolve()
|
|
247
|
+
// rejects a caller whose path the mount does not contain.
|
|
248
|
+
if (containsPath(canonicalMount, path)) {
|
|
249
|
+
this.__cacheStore(canonicalMount, entry);
|
|
250
|
+
}
|
|
195
251
|
// Clear in-flight
|
|
196
252
|
this.__inflight.delete(interimKey);
|
|
197
253
|
return entry;
|
|
@@ -199,9 +255,12 @@ class MountResolver {
|
|
|
199
255
|
.catch((err) => {
|
|
200
256
|
// Clear in-flight so callers can retry
|
|
201
257
|
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`.
|
|
202
260
|
throw new VaultError(
|
|
203
|
-
`Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err
|
|
204
|
-
'Set api.engines or disable autoDetect to bypass detection.'
|
|
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 }
|
|
205
264
|
);
|
|
206
265
|
});
|
|
207
266
|
|
|
@@ -209,6 +268,26 @@ class MountResolver {
|
|
|
209
268
|
return promise;
|
|
210
269
|
}
|
|
211
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The cache key for a detected mount. For auth mounts Vault reports the path without the
|
|
273
|
+
* reserved "auth/" prefix (auth/apps/role/r -> "apps/"), so it is re-added and the entry
|
|
274
|
+
* cannot collide with a secrets mount of the same name. A mount in a child namespace is
|
|
275
|
+
* reported with the client's namespace in front; that prefix is dropped when only the
|
|
276
|
+
* shorter form contains the path.
|
|
277
|
+
*/
|
|
278
|
+
__canonicalMount(reportedPath, path) {
|
|
279
|
+
const withTable = (mount) => (path.startsWith('auth/') ? `auth/${mount}` : mount);
|
|
280
|
+
const reported = trimTrailingSlashes(reportedPath);
|
|
281
|
+
const mount = withTable(reported);
|
|
282
|
+
if (!containsPath(mount, path) && this.__namespacePrefix && reported.startsWith(this.__namespacePrefix)) {
|
|
283
|
+
const relative = withTable(reported.slice(this.__namespacePrefix.length));
|
|
284
|
+
if (containsPath(relative, path)) {
|
|
285
|
+
return relative;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return mount;
|
|
289
|
+
}
|
|
290
|
+
|
|
212
291
|
/**
|
|
213
292
|
* Simple first-segment extraction used only for the disabled path.
|
|
214
293
|
*/
|
package/src/VaultApiClient.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const errors = require('./errors');
|
|
4
|
+
const { trimTrailingSlashes } = require('./slashes');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Joins URL segments with single slashes while preserving the leading
|
|
@@ -14,7 +15,7 @@ function joinUrl(...parts) {
|
|
|
14
15
|
segment = segment.replace(/^\/+/, '');
|
|
15
16
|
}
|
|
16
17
|
if (i < parts.length - 1) {
|
|
17
|
-
segment = segment
|
|
18
|
+
segment = trimTrailingSlashes(segment);
|
|
18
19
|
}
|
|
19
20
|
return segment;
|
|
20
21
|
})
|
package/src/VaultClient.js
CHANGED
|
@@ -12,6 +12,8 @@ const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
|
|
|
12
12
|
const VaultJwtAuth = require('./auth/VaultJwtAuth');
|
|
13
13
|
const MountResolver = require('./MountResolver');
|
|
14
14
|
const { rewritePath, normalizeResponse } = require('./kvTransform');
|
|
15
|
+
const safeMessage = require('./safeMessage');
|
|
16
|
+
const { trimTrailingSlashes } = require('./slashes');
|
|
15
17
|
|
|
16
18
|
const noop = () => {};
|
|
17
19
|
const vaultInstances = {};
|
|
@@ -46,6 +48,74 @@ function optionsDigest(options) {
|
|
|
46
48
|
return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
// fetch() parses the joined URL per the WHATWG URL Standard: '?' starts the query, '#' the
|
|
52
|
+
// fragment, '\' is a path separator for http(s), tab/LF/CR are deleted anywhere and trailing
|
|
53
|
+
// C0 controls and spaces are stripped. Any of these lets a KV path address a different key.
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
const UNSAFE_KV_PATH_CHARS = /[?#\\\u0000-\u001f\u007f]/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rejects a KV method path that the URL parser or Vault would resolve to a path other than
|
|
59
|
+
* `<mount>/<kv segment>/<logical path>` (GHSA-rr65-m54w-rfr9, GHSA-wf8v-q2h3-hgjm).
|
|
60
|
+
*
|
|
61
|
+
* fetch() removes '.' and '..' segments, and Vault redirects a raw '//' to the collapsed path,
|
|
62
|
+
* which fetch() follows (a 301 on Vault 1.x turns a write into a read, a 307 on 2.x writes the
|
|
63
|
+
* parent key). Encoded forms are rejected rather than decoded: Vault decodes %2e and %2f before
|
|
64
|
+
* its KV v2 engine resolves '..' and collapses empty segments, and Vault 2.x decodes and
|
|
65
|
+
* path-cleans again on every redirect hop, so a %25-encoded escape becomes %2e or %2f on the
|
|
66
|
+
* first hop and a real dot segment or separator on the second. No other escape can form a dot
|
|
67
|
+
* segment or a separator, so the segments are then checked as written.
|
|
68
|
+
*
|
|
69
|
+
* One trailing slash is allowed (list() addresses a folder with it, and the other methods
|
|
70
|
+
* pass it through byte for byte), except on the KV v2 helpers: those always address a single
|
|
71
|
+
* key, and Vault applies a metadata call on 'tenants/me/' to the key 'tenants/me', so
|
|
72
|
+
* deleteMetadata() would delete it. Unpaired surrogates are rejected because fetch() encodes
|
|
73
|
+
* every one of them as U+FFFD.
|
|
74
|
+
*
|
|
75
|
+
* The messages never include the path: the KV methods log them at error level, and the path
|
|
76
|
+
* may be built from user data that was never logged before.
|
|
77
|
+
*
|
|
78
|
+
* The mount (first segment) must not be percent-encoded: the engines override and mount
|
|
79
|
+
* detection match the path as written, but Vault decodes it, so 'secre%74/config' would skip
|
|
80
|
+
* the KV v2 rewrite and reach the mount's own config endpoint.
|
|
81
|
+
*
|
|
82
|
+
* @param {*} path - caller-supplied secret path
|
|
83
|
+
* @param {boolean} allowTrailingSlash - false for the KV v2 helpers
|
|
84
|
+
* @returns {string} the path without leading slashes, which VaultApiClient drops from the URL
|
|
85
|
+
* anyway; resolving the mount on it keeps the KV v2 rewrite from taking '/secret/foo'
|
|
86
|
+
* for a key in 'secret/'
|
|
87
|
+
* @throws {InvalidArgumentsError}
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
function assertSafeKvPath(path, allowTrailingSlash) {
|
|
91
|
+
if (typeof path !== 'string' || path === '') {
|
|
92
|
+
throw new errors.InvalidArgumentsError('Invalid secret path: expected a non-empty string');
|
|
93
|
+
}
|
|
94
|
+
if (UNSAFE_KV_PATH_CHARS.test(path) || path.endsWith(' ') || !path.isWellFormed()) {
|
|
95
|
+
throw new errors.InvalidArgumentsError(
|
|
96
|
+
'Invalid secret path: \'?\', \'#\', \'\\\', control characters, unpaired surrogates and a ' +
|
|
97
|
+
'trailing space are not allowed'
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (/%(2e|2f|5c|25)/i.test(path)) {
|
|
101
|
+
throw new errors.InvalidArgumentsError(
|
|
102
|
+
'Invalid secret path: encoded dots, separators or percent signs are not allowed'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const trimmed = path.replace(/^\/+/, '');
|
|
106
|
+
if (trimmed.split('/')[0].includes('%')) {
|
|
107
|
+
throw new errors.InvalidArgumentsError('Invalid secret path: the mount must not be percent-encoded');
|
|
108
|
+
}
|
|
109
|
+
const segments = (allowTrailingSlash ? trimmed.replace(/\/$/, '') : trimmed).split('/');
|
|
110
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
111
|
+
throw new errors.InvalidArgumentsError(
|
|
112
|
+
'Invalid secret path: empty, \'.\' and \'..\' segments are not allowed ' +
|
|
113
|
+
'(nor a trailing slash in the KV v2 helpers)'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
49
119
|
class VaultClient {
|
|
50
120
|
|
|
51
121
|
/**
|
|
@@ -127,6 +197,7 @@ class VaultClient {
|
|
|
127
197
|
|
|
128
198
|
this.__resolver = new MountResolver(detectFn, engines, this.__log, {
|
|
129
199
|
disabled: resolverDisabled,
|
|
200
|
+
namespace,
|
|
130
201
|
});
|
|
131
202
|
}
|
|
132
203
|
|
|
@@ -341,11 +412,22 @@ class VaultClient {
|
|
|
341
412
|
* engine version (merge-patch update() semantics); by default only v2
|
|
342
413
|
* write/update wrap non-null data and everything else passes through
|
|
343
414
|
* @returns {Promise<{body: *, version: (number|undefined), mount: (string|undefined), apiPath: string}>}
|
|
415
|
+
* rejects with InvalidArgumentsError, before any request, for an unsafe KV path
|
|
344
416
|
* @private
|
|
345
417
|
*/
|
|
346
418
|
__resolveAndRequest(op, method, path, data, extraHeaders, options) {
|
|
347
419
|
const { raw = false, v2Only = false, wrapData = false } = options || {};
|
|
348
420
|
|
|
421
|
+
// KV paths are validated before the token fetch, mount resolution and the autoDetect
|
|
422
|
+
// detection request; request() (raw) keeps its documented literal-path semantics.
|
|
423
|
+
if (!raw) {
|
|
424
|
+
try {
|
|
425
|
+
path = assertSafeKvPath(path, !v2Only);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
return Promise.reject(err);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
349
431
|
return this.__auth.getAuthToken()
|
|
350
432
|
.then((token) => {
|
|
351
433
|
const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
|
|
@@ -398,10 +480,17 @@ class VaultClient {
|
|
|
398
480
|
* @private
|
|
399
481
|
*/
|
|
400
482
|
__logicalPath(path, mount) {
|
|
401
|
-
const prefix = mount
|
|
483
|
+
const prefix = trimTrailingSlashes(mount);
|
|
402
484
|
if (path === prefix) return '';
|
|
403
485
|
if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
|
|
404
|
-
|
|
486
|
+
// Detection decodes the path, so with a multi-segment mount 'team/k%76/foo' resolves to
|
|
487
|
+
// 'team/kv'; rewriting it as a key inside that mount would address 'team/kv/team/kv/foo'
|
|
488
|
+
// (GHSA-rr65-m54w-rfr9). MountResolver.resolve() already rejects that; this guards the
|
|
489
|
+
// rewrite itself.
|
|
490
|
+
throw new errors.InvalidArgumentsError(
|
|
491
|
+
`Invalid secret path: it does not start with the mount "${prefix}" it resolved to; ` +
|
|
492
|
+
'do not percent-encode the mount'
|
|
493
|
+
);
|
|
405
494
|
}
|
|
406
495
|
|
|
407
496
|
// -------------------------------------------------------------------------
|
|
@@ -422,7 +511,7 @@ class VaultClient {
|
|
|
422
511
|
return Lease.fromResponse(normalised);
|
|
423
512
|
})
|
|
424
513
|
.catch((reason) => {
|
|
425
|
-
this.__log.error('read secret failed: %s', reason
|
|
514
|
+
this.__log.error('read secret failed: %s', safeMessage(reason));
|
|
426
515
|
throw reason;
|
|
427
516
|
});
|
|
428
517
|
}
|
|
@@ -442,7 +531,7 @@ class VaultClient {
|
|
|
442
531
|
return Lease.fromResponse(normalised);
|
|
443
532
|
})
|
|
444
533
|
.catch((reason) => {
|
|
445
|
-
this.__log.error('list secrets failed: %s', reason
|
|
534
|
+
this.__log.error('list secrets failed: %s', safeMessage(reason));
|
|
446
535
|
throw reason;
|
|
447
536
|
});
|
|
448
537
|
}
|
|
@@ -462,7 +551,7 @@ class VaultClient {
|
|
|
462
551
|
return body;
|
|
463
552
|
})
|
|
464
553
|
.catch((reason) => {
|
|
465
|
-
this.__log.error('write secret failed: %s', reason
|
|
554
|
+
this.__log.error('write secret failed: %s', safeMessage(reason));
|
|
466
555
|
throw reason;
|
|
467
556
|
});
|
|
468
557
|
}
|
|
@@ -484,7 +573,7 @@ class VaultClient {
|
|
|
484
573
|
return body;
|
|
485
574
|
})
|
|
486
575
|
.catch((reason) => {
|
|
487
|
-
this.__log.error('delete secret failed: %s', reason
|
|
576
|
+
this.__log.error('delete secret failed: %s', safeMessage(reason));
|
|
488
577
|
throw reason;
|
|
489
578
|
});
|
|
490
579
|
}
|
|
@@ -509,14 +598,15 @@ class VaultClient {
|
|
|
509
598
|
{ 'Content-Type': 'application/merge-patch+json' }, { wrapData: true })
|
|
510
599
|
.then(({ body }) => body)
|
|
511
600
|
.catch((reason) => {
|
|
512
|
-
this.__log.error('update secret failed: %s', reason
|
|
601
|
+
this.__log.error('update secret failed: %s', safeMessage(reason));
|
|
513
602
|
throw reason;
|
|
514
603
|
});
|
|
515
604
|
}
|
|
516
605
|
|
|
517
606
|
/**
|
|
518
607
|
* Raw request — literal path, no path rewriting or response unwrapping.
|
|
519
|
-
* Returns the parsed body directly.
|
|
608
|
+
* Returns the parsed body directly. The path is not validated: never build it from
|
|
609
|
+
* untrusted input.
|
|
520
610
|
*
|
|
521
611
|
* @param {string} method - HTTP method
|
|
522
612
|
* @param {string} path - literal API path
|
|
@@ -528,7 +618,7 @@ class VaultClient {
|
|
|
528
618
|
return this.__resolveAndRequest('request', method, path, data === undefined ? null : data, null, { raw: true })
|
|
529
619
|
.then(({ body }) => body)
|
|
530
620
|
.catch((reason) => {
|
|
531
|
-
this.__log.error('raw request failed: %s', reason
|
|
621
|
+
this.__log.error('raw request failed: %s', safeMessage(reason));
|
|
532
622
|
throw reason;
|
|
533
623
|
});
|
|
534
624
|
}
|
|
@@ -549,7 +639,7 @@ class VaultClient {
|
|
|
549
639
|
this.__log.debug('deleteVersions %s %j', path, versions);
|
|
550
640
|
return this.__v2Only('deleteVersions', 'POST', path, { versions })
|
|
551
641
|
.catch((reason) => {
|
|
552
|
-
this.__log.error('deleteVersions failed: %s', reason
|
|
642
|
+
this.__log.error('deleteVersions failed: %s', safeMessage(reason));
|
|
553
643
|
throw reason;
|
|
554
644
|
});
|
|
555
645
|
}
|
|
@@ -566,7 +656,7 @@ class VaultClient {
|
|
|
566
656
|
this.__log.debug('undeleteVersions %s %j', path, versions);
|
|
567
657
|
return this.__v2Only('undeleteVersions', 'POST', path, { versions })
|
|
568
658
|
.catch((reason) => {
|
|
569
|
-
this.__log.error('undeleteVersions failed: %s', reason
|
|
659
|
+
this.__log.error('undeleteVersions failed: %s', safeMessage(reason));
|
|
570
660
|
throw reason;
|
|
571
661
|
});
|
|
572
662
|
}
|
|
@@ -583,7 +673,7 @@ class VaultClient {
|
|
|
583
673
|
this.__log.debug('destroyVersions %s %j', path, versions);
|
|
584
674
|
return this.__v2Only('destroyVersions', 'POST', path, { versions })
|
|
585
675
|
.catch((reason) => {
|
|
586
|
-
this.__log.error('destroyVersions failed: %s', reason
|
|
676
|
+
this.__log.error('destroyVersions failed: %s', safeMessage(reason));
|
|
587
677
|
throw reason;
|
|
588
678
|
});
|
|
589
679
|
}
|
|
@@ -602,7 +692,7 @@ class VaultClient {
|
|
|
602
692
|
return normalizeResponse(2, 'readMetadata', body);
|
|
603
693
|
})
|
|
604
694
|
.catch((reason) => {
|
|
605
|
-
this.__log.error('readMetadata failed: %s', reason
|
|
695
|
+
this.__log.error('readMetadata failed: %s', safeMessage(reason));
|
|
606
696
|
throw reason;
|
|
607
697
|
});
|
|
608
698
|
}
|
|
@@ -618,7 +708,7 @@ class VaultClient {
|
|
|
618
708
|
this.__log.debug('deleteMetadata %s', path);
|
|
619
709
|
return this.__v2Only('deleteMetadata', 'DELETE', path, null)
|
|
620
710
|
.catch((reason) => {
|
|
621
|
-
this.__log.error('deleteMetadata failed: %s', reason
|
|
711
|
+
this.__log.error('deleteMetadata failed: %s', safeMessage(reason));
|
|
622
712
|
throw reason;
|
|
623
713
|
});
|
|
624
714
|
}
|
package/src/VaultNodeConfig.js
CHANGED
|
@@ -8,6 +8,26 @@ function isObject(value) {
|
|
|
8
8
|
return value !== null && typeof value === 'object';
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Resolves NODE_CONFIG_DIR the same way `config`'s own `util.initParam` historically did —
|
|
13
|
+
* a `--NODE_CONFIG_DIR=value` command-line argument, then the environment variable, then
|
|
14
|
+
* `defaultValue` — implemented directly rather than through `config.util.initParam`, which
|
|
15
|
+
* config 4.x removed from its public API without documenting the removal (`config.util` is
|
|
16
|
+
* now a `ConfigUtils` instance with no `initParam` method at all; calling it throws
|
|
17
|
+
* "config.util.initParam is not a function"). This also means resolution no longer depends
|
|
18
|
+
* on which `config` major is installed.
|
|
19
|
+
*
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
function resolveConfigDir(defaultValue) {
|
|
23
|
+
const argPrefix = '--NODE_CONFIG_DIR=';
|
|
24
|
+
const cmdLineArg = process.argv.slice(2).find((arg) => arg.startsWith(argPrefix));
|
|
25
|
+
if (cmdLineArg !== undefined) {
|
|
26
|
+
return cmdLineArg.slice(argPrefix.length);
|
|
27
|
+
}
|
|
28
|
+
return process.env.NODE_CONFIG_DIR || defaultValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
function isPlainObject(value) {
|
|
12
32
|
if (!isObject(value)) {
|
|
13
33
|
return false;
|
|
@@ -112,7 +132,7 @@ class VaultNodeConfig {
|
|
|
112
132
|
* @private
|
|
113
133
|
*/
|
|
114
134
|
__getSubstitutionMap() {
|
|
115
|
-
let configDir =
|
|
135
|
+
let configDir = resolveConfigDir(path.join(process.cwd(), 'config'));
|
|
116
136
|
if (configDir.indexOf('.') === 0) {
|
|
117
137
|
configDir = path.join(process.cwd(), configDir);
|
|
118
138
|
}
|
|
@@ -27,9 +27,10 @@ class VaultAppRoleAuth extends VaultBaseAuth {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
_authenticate() {
|
|
30
|
+
// Never log the role_id: for a role with bind_secret_id=false it is the whole login credential.
|
|
30
31
|
this._log.info(
|
|
31
|
-
'making authentication request:
|
|
32
|
-
this.
|
|
32
|
+
'making authentication request: approle mount=%s',
|
|
33
|
+
this._mount
|
|
33
34
|
);
|
|
34
35
|
|
|
35
36
|
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
|
|
@@ -10,6 +10,7 @@ const AuthToken = require('./AuthToken');
|
|
|
10
10
|
/** Renew at the halfway point of the token's remaining lifetime. */
|
|
11
11
|
const DEFAULT_RENEWAL_FRACTION = 0.5;
|
|
12
12
|
const errors = require('../errors');
|
|
13
|
+
const safeMessage = require('../safeMessage');
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Formats a millisecond duration as a short human-readable string
|
|
@@ -285,8 +286,14 @@ class VaultBaseAuth {
|
|
|
285
286
|
}
|
|
286
287
|
this.__setupTokenRefreshTimer(authToken);
|
|
287
288
|
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
const message = `Cannot refresh auth token with "${authToken.getAccessor()}" accessor.`;
|
|
290
|
+
// Dumping a VaultHttpError would print its raw response body, so it gets the safe
|
|
291
|
+
// message only. Any other error is logged whole: its stack already has the message.
|
|
292
|
+
if (err instanceof errors.VaultHttpError) {
|
|
293
|
+
this._log.error(`${message} Error: ${safeMessage(err)}`);
|
|
294
|
+
} else {
|
|
295
|
+
this._log.error(message, err);
|
|
296
|
+
}
|
|
290
297
|
});
|
|
291
298
|
}, timer);
|
|
292
299
|
|
|
@@ -313,7 +320,7 @@ class VaultBaseAuth {
|
|
|
313
320
|
return this._getTokenEntity(authToken.getId());
|
|
314
321
|
})
|
|
315
322
|
.catch((reason) => {
|
|
316
|
-
this._log.error('token renew failed: %s', reason
|
|
323
|
+
this._log.error('token renew failed: %s', safeMessage(reason));
|
|
317
324
|
throw reason;
|
|
318
325
|
});
|
|
319
326
|
}
|
package/src/auth/VaultJwtAuth.js
CHANGED
|
@@ -21,6 +21,15 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
21
21
|
* returning `string | Promise<string>`. Called fresh on every login (never at construction
|
|
22
22
|
* or cached across logins) so it can mint a short-lived token -- the shape GitHub Actions'
|
|
23
23
|
* `core.getIDToken()`, cloud metadata endpoints and SPIFFE workloads need.
|
|
24
|
+
* @param {String} [config.distributedClaimAccessToken] - A literal OAuth access token forwarded
|
|
25
|
+
* to Vault as `distributed_claim_access_token`. Azure/Entra roles with `fetch_groups` enabled
|
|
26
|
+
* need it so Vault can resolve the distributed group-membership claim against the Microsoft
|
|
27
|
+
* Graph API. Optional, and mutually exclusive with `distributedClaimAccessTokenProvider`.
|
|
28
|
+
* @param {Function} [config.distributedClaimAccessTokenProvider] - (optionally async) function
|
|
29
|
+
* invoked at login time, returning `string | Promise<string>`. Called fresh on every login
|
|
30
|
+
* (never at construction or cached across logins) because Graph access tokens are short-lived
|
|
31
|
+
* and are usually acquired next to the JWT itself. Mutually exclusive with
|
|
32
|
+
* `distributedClaimAccessToken`.
|
|
24
33
|
* @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
|
|
25
34
|
* header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
|
|
26
35
|
* @param {String} mount - Vault's mount point ("jwt" by default)
|
|
@@ -37,34 +46,57 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
37
46
|
if (config.jwtProvider !== undefined && typeof config.jwtProvider !== 'function') {
|
|
38
47
|
throw new errors.InvalidArgumentsError('"jwtProvider" should be a function for VaultJwtAuth');
|
|
39
48
|
}
|
|
49
|
+
if (config.distributedClaimAccessToken !== undefined
|
|
50
|
+
&& config.distributedClaimAccessTokenProvider !== undefined) {
|
|
51
|
+
throw new errors.InvalidArgumentsError(
|
|
52
|
+
'Only one of "distributedClaimAccessToken" or "distributedClaimAccessTokenProvider"'
|
|
53
|
+
+ ' should be provided for VaultJwtAuth'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (config.distributedClaimAccessTokenProvider !== undefined
|
|
57
|
+
&& typeof config.distributedClaimAccessTokenProvider !== 'function') {
|
|
58
|
+
throw new errors.InvalidArgumentsError(
|
|
59
|
+
'"distributedClaimAccessTokenProvider" should be a function for VaultJwtAuth'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
40
62
|
|
|
41
63
|
this.__role = config.role;
|
|
42
64
|
this.__jwt = config.jwt;
|
|
43
65
|
this.__jwtPath = config.jwtPath;
|
|
44
66
|
this.__jwtProvider = config.jwtProvider;
|
|
67
|
+
this.__distributedClaimAccessToken = config.distributedClaimAccessToken;
|
|
68
|
+
this.__distributedClaimAccessTokenProvider = config.distributedClaimAccessTokenProvider;
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
_authenticate() {
|
|
48
72
|
return Promise.resolve()
|
|
49
73
|
.then(() => this.__acquireJwt())
|
|
50
|
-
.then(({ jwt, source }) =>
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
74
|
+
.then(({ jwt, source }) => Promise.resolve()
|
|
75
|
+
.then(() => this.__acquireDistributedClaimAccessToken())
|
|
76
|
+
.then((distributedClaim) => {
|
|
77
|
+
this._log.info(
|
|
78
|
+
'making authentication request: Vault role: "%s"; JWT source: %s (%d bytes)%s',
|
|
79
|
+
this.__role !== undefined ? this.__role : '(default_role)', source, jwt.length,
|
|
80
|
+
distributedClaim === undefined
|
|
81
|
+
? ''
|
|
82
|
+
: `; distributed claim access token: ${distributedClaim.source}`
|
|
83
|
+
);
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
const body = { jwt };
|
|
86
|
+
if (this.__role !== undefined) {
|
|
87
|
+
body.role = this.__role;
|
|
88
|
+
}
|
|
89
|
+
if (distributedClaim !== undefined) {
|
|
90
|
+
body.distributed_claim_access_token = distributedClaim.accessToken;
|
|
91
|
+
}
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
93
|
+
return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, body)
|
|
94
|
+
.then((res) => {
|
|
95
|
+
this._log.debug('received Vault client token from JWT login');
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
return this._getTokenEntity(res.auth.client_token);
|
|
98
|
+
});
|
|
99
|
+
}));
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
/**
|
|
@@ -90,6 +122,33 @@ class VaultJwtAuth extends VaultBaseAuth {
|
|
|
90
122
|
return { jwt, source: 'provider' };
|
|
91
123
|
});
|
|
92
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolves to `undefined` when neither option is configured, so that the login body stays
|
|
128
|
+
* byte-identical to what it was before this option existed.
|
|
129
|
+
*
|
|
130
|
+
* @returns {undefined|{accessToken: String, source: String}|Promise<{accessToken: String, source: String}>}
|
|
131
|
+
* @private
|
|
132
|
+
*/
|
|
133
|
+
__acquireDistributedClaimAccessToken() {
|
|
134
|
+
if (this.__distributedClaimAccessToken !== undefined) {
|
|
135
|
+
return { accessToken: this.__distributedClaimAccessToken, source: 'literal' };
|
|
136
|
+
}
|
|
137
|
+
if (this.__distributedClaimAccessTokenProvider === undefined) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wrapping in Promise.resolve().then() normalizes both a sync provider (plain return)
|
|
142
|
+
// and a synchronous throw into the same rejection path as an async one.
|
|
143
|
+
return Promise.resolve().then(() => this.__distributedClaimAccessTokenProvider()).then((accessToken) => {
|
|
144
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
|
145
|
+
throw new errors.InvalidArgumentsError(
|
|
146
|
+
'"distributedClaimAccessTokenProvider" must resolve to a non-empty access token string'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return { accessToken, source: 'provider' };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
93
152
|
}
|
|
94
153
|
|
|
95
154
|
module.exports = VaultJwtAuth;
|
package/src/kvTransform.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* normalizeResponse(version, op, body) -> normalized body
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
const { trimTrailingSlashes } = require('./slashes');
|
|
12
|
+
|
|
11
13
|
// Map of op -> v2 path segment inserted between mount and logical path.
|
|
12
14
|
const V2_SEGMENT = {
|
|
13
15
|
read: 'data',
|
|
@@ -33,7 +35,7 @@ const V2_SEGMENT = {
|
|
|
33
35
|
*/
|
|
34
36
|
function rewritePath(version, op, mount, logicalPath) {
|
|
35
37
|
// Normalise trailing slash on mount
|
|
36
|
-
const m = mount
|
|
38
|
+
const m = trimTrailingSlashes(mount);
|
|
37
39
|
|
|
38
40
|
if (version !== 2) {
|
|
39
41
|
// v1 / non-kv: simple concatenation
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { VaultHttpError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A message for `err` that is safe to log or to embed in another error's message.
|
|
7
|
+
*
|
|
8
|
+
* A {@link VaultHttpError}'s own message is `"<status> - <raw body text>"` (kept for
|
|
9
|
+
* compatibility), and a non-2xx Vault body can still carry secret material: a transit batch
|
|
10
|
+
* decrypt with one bad item answers 400 with the other items' plaintexts in
|
|
11
|
+
* `data.batch_results`. So for those only the status and Vault's own `errors` strings are
|
|
12
|
+
* kept. Any other error's message is returned unchanged, and a rejection value that is not an
|
|
13
|
+
* error (`undefined`, a string) is stringified, so the catch handler logging it cannot throw.
|
|
14
|
+
*
|
|
15
|
+
* @param {*} err
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function safeMessage(err) {
|
|
19
|
+
if (!(err instanceof VaultHttpError)) {
|
|
20
|
+
return err && err.message !== undefined ? err.message : String(err);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const body = err.error;
|
|
24
|
+
if (!body || !Array.isArray(body.errors)) {
|
|
25
|
+
return `${err.statusCode} - (response body omitted)`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const vaultErrors = body.errors.filter((e) => typeof e === 'string');
|
|
29
|
+
return `${err.statusCode} - ${JSON.stringify({ errors: vaultErrors })}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = safeMessage;
|
package/src/slashes.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Slash trimming without a regex: /\/+$/ is tried at every position of a run of slashes, so it is
|
|
4
|
+
// quadratic on a long run that does not end the string ('a' + '/'.repeat(40000) + 'x' takes
|
|
5
|
+
// half a second), and its inputs (namespace, engines keys, paths, detection responses) come from
|
|
6
|
+
// outside the library (CodeQL js/polynomial-redos).
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} value
|
|
10
|
+
* @returns {string} `value` without trailing slashes
|
|
11
|
+
*/
|
|
12
|
+
function trimTrailingSlashes(value) {
|
|
13
|
+
let end = value.length;
|
|
14
|
+
while (end > 0 && value[end - 1] === '/') {
|
|
15
|
+
end--;
|
|
16
|
+
}
|
|
17
|
+
return value.slice(0, end);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} value
|
|
22
|
+
* @returns {string} `value` without leading or trailing slashes
|
|
23
|
+
*/
|
|
24
|
+
function trimSlashes(value) {
|
|
25
|
+
let start = 0;
|
|
26
|
+
while (start < value.length && value[start] === '/') {
|
|
27
|
+
start++;
|
|
28
|
+
}
|
|
29
|
+
return trimTrailingSlashes(value.slice(start));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { trimTrailingSlashes, trimSlashes };
|