node-vault-client 2.3.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,13 +16,11 @@ npm install --save node-vault-client
16
16
 
17
17
  ### Requirements
18
18
 
19
- Node.js >= 18 — the client uses the native `fetch` API.
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.
20
22
 
21
- > **Node.js 18 sunset notice:** this is the last release of node-vault-client to support Node 18.
22
- > Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from the
23
- > Node.js project. Running this version under Node 18 emits a one-time process warning
24
- > (`NodeVaultClientNode18Deprecation`); the next major release raises the minimum to
25
- > Node.js >= 20.19.0. See [CHANGELOG.md](CHANGELOG.md) for details.
23
+ Upgrading from 2.x? [MIGRATION.md](MIGRATION.md) lists what can need a change and what to check.
26
24
 
27
25
  ### TypeScript
28
26
 
@@ -367,19 +365,31 @@ on the next call — no background timer at all. Two reasons to want that:
367
365
  Renewal off means the token is allowed to expire, and what happens next depends on whether your
368
366
  backend can obtain a *fresh* credential unaided:
369
367
 
370
- | backend | on expiry with `renewal: false` |
371
- | --- | --- |
372
- | `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case |
373
- | `appRole` | re-login **replays the same `secret_id`**. Fine for a reusable one; with Vault's recommended hardening (`secret_id_num_uses=1`, or a short `secret_id_ttl`) the second login is rejected |
374
- | `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires |
375
- | `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently |
368
+ | backend | on expiry with `renewal: false` | at max TTL with renewal enabled |
369
+ | --- | --- | --- |
370
+ | `kubernetes`, `iam`, `jwt` with `jwtPath`/`jwtProvider` | clean re-login — the JWT or AWS credential is re-acquired, so this is the intended case | clean re-login on the next call after the client-side expiry deadline |
371
+ | `appRole` | re-login **replays the same `secret_id`**. Fine for a reusable one; with Vault's recommended hardening (`secret_id_num_uses=1`, or a short `secret_id_ttl`) the second login is rejected | re-login replays the same `secret_id`; a single-use or expired secret is rejected |
372
+ | `jwt` with a literal `jwt` | replays the same JWT, so it works only until the IdP-issued token expires | replays the same JWT; once it expires, login is rejected |
373
+ | `token` | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` from then on, permanently | **cannot re-authenticate at all.** The client raises `AuthTokenExpiredError` after the max-TTL expiry |
374
+
375
+ #### Tokens that reach their maximum TTL
376
+
377
+ Vault stops extending a renewable token when it reaches its maximum TTL. The client uses Vault's
378
+ absolute `expire_time` when available, treats `ttl: 0` as expired immediately, and stops background
379
+ renewal when successive absolute expiry deadlines do not materially advance. Because Vault reports
380
+ whole-second TTLs, the scheduler also compares the reported TTL with the elapsed renewal interval;
381
+ short periodic tokens keep renewing even when each expiry advance is one second or less. A
382
+ `renewalIncrement` that meaningfully shortens the expiry still schedules from the new deadline. For positive TTLs above 60 seconds, the client keeps a one-minute
383
+ safety margin for requests in flight; TTLs of 60 seconds or less have no margin. The next call after
384
+ the client-side expiry deadline re-authenticates, as shown in the table above.
376
385
 
377
386
  Two consequences worth stating plainly:
378
387
 
379
- * For `token` auth this **is** a behaviour change, not a no-op. A renewable token handed to `token`
380
- auth is renewed indefinitely today; with `renewal: false` it expires and every later call rejects
381
- for the life of the process. `close()` does not reset it — recovery means
382
- `VaultClient.clear(name)` and booting again.
388
+ * **For `token` auth, there is no fresh credential for re-authentication.** With `renewal: false`,
389
+ an otherwise renewable token is allowed to expire without a background renewal. With renewal
390
+ enabled, the client stops the background loop once Vault no longer extends the expiry materially.
391
+ After expiry every later call rejects for the life of the process. `close()` does not reset it —
392
+ recovery means `VaultClient.clear(name)` and booting again.
383
393
  * For `appRole` and literal-`jwt`, a login that can no longer succeed is retried on **every**
384
394
  subsequent call, since a failed login clears the cached token. That is a failing request per
385
395
  `read()`, with no backoff. Prefer `jwtPath`/`jwtProvider`, a reusable `secret_id`, or leaving
@@ -412,8 +422,8 @@ auth: {
412
422
  Lower `renewalFraction` values renew earlier and more often, which buys headroom if Vault is briefly
413
423
  unreachable. A failed renewal is retried on the same rule against the *same* token, so the waits
414
424
  shrink geometrically as the remaining lifetime does — for a 1-hour token that is roughly 12 attempts
415
- at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 1-second floor just
416
- before expiry. Higher values renew later and talk to Vault less.
425
+ at `0.5` (1800s, 900s, 450s, …) versus 27 at `0.25`, both bottoming out at a 250 ms minimum delay
426
+ when it still fits before expiry. Higher values renew later and talk to Vault less.
417
427
 
418
428
  `renewalIncrement` is a request, not a guarantee: Vault grants at most the token's max TTL and may
419
429
  return less. Both keys are validated at construction — a `renewalFraction` outside `(0, 1)` or a
@@ -477,7 +487,7 @@ Client constructor function.
477
487
  | [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). |
478
488
  | [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)`. |
479
489
  | [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. |
480
- | [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 so that sensitive data is never printed. Pass `false` to disable logging entirely. |
490
+ | [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. |
481
491
 
482
492
  ##### Custom transport (proxy / self-signed TLS)
483
493
 
@@ -634,7 +644,8 @@ Raw request — escape hatch for any Vault backend
634
644
 
635
645
  Sends the literal API path with no KV path rewriting and no response unwrapping, and
636
646
  resolves to the parsed response body. Use it for non-KV backends (e.g. Transit) or when you
637
- have already constructed the complete Vault API path.
647
+ have already constructed the complete Vault API path. The path is **not validated** (see
648
+ [Path validation](#path-validation)), so never build it from untrusted input.
638
649
 
639
650
  **Kind**: instance method of [<code>VaultClient</code>](#VaultClient)
640
651
 
@@ -906,6 +917,11 @@ const result = await client.request('POST', 'transit/encrypt/my-key', {
906
917
  console.log(result.data.ciphertext);
907
918
  ```
908
919
 
920
+ Transit batch encrypt/decrypt answers HTTP 400 when any item fails, and the rejection's
921
+ `err.error.data.batch_results` still holds the successful items. Pass
922
+ `partial_failure_response_code: 200` (Vault 1.12+) and check each item's `error` if you don't want
923
+ that data on an error path.
924
+
909
925
  ### Lease.getMetadata()
910
926
 
911
927
  `getMetadata()` is additive — existing code is unaffected.
@@ -941,10 +957,51 @@ rewriting:
941
957
  await client.request('GET', 'secret/data/foo');
942
958
  ```
943
959
 
960
+ ### Path validation
961
+
962
+ The KV methods (`read`, `list`, `write`, `delete`, `update` and the KV v2 helpers) reject a
963
+ path with `InvalidArgumentsError`, before any request is sent, when the URL parser or Vault
964
+ would resolve it to a different path than the one passed:
965
+
966
+ - an empty path;
967
+ - `?`, `#`, `\`, a control character (U+0000–U+001F, U+007F), an unpaired surrogate or a
968
+ trailing space;
969
+ - a percent-encoded `.`, `/`, `\` or `%` (`%2e`, `%2f`, `%5c`, `%25`, in any case). Vault decodes
970
+ these and cleans the path again on every redirect hop, so they are rejected rather than decoded;
971
+ - an empty segment such as `a//b` or `a//`, which Vault collapses into the parent path, so
972
+ `tenants/${id}/key` with an empty `id` would address `tenants/key`;
973
+ - a `.` or `..` segment;
974
+ - a percent-encoded mount (`secre%74/foo`): the `engines` override and mount detection match the
975
+ path as written, but Vault decodes it, so `secre%74/config` would skip the KV v2 rewrite and
976
+ reach the mount's own `config` endpoint;
977
+ - a trailing slash on the KV v2 helpers (`deleteVersions`, `undeleteVersions`,
978
+ `destroyVersions`, `readMetadata`, `deleteMetadata`): Vault applies a metadata call on
979
+ `tenants/me/` to the key `tenants/me`.
980
+
981
+ Leading slashes are dropped before the mount is resolved, so `/secret/foo` and `secret/foo`
982
+ address the same key (an `engines` key may be written either way). The other methods keep a
983
+ single trailing slash, as in `list('secret/apps/')`. A percent-encoded segment of a
984
+ multi-segment mount (`team/k%76/foo` for the mount `team/kv`) is rejected too: by the `engines`
985
+ lookup, or after detection with `autoDetect`.
986
+
987
+ A key name that needs `?` or `#` can still be addressed percent-encoded (`%3F`, `%23`); Vault
988
+ decodes it. A key that needs any other rejected character goes through `request()`, as does a
989
+ specific KV v2 version, which used to work as `read('secret/foo?version=2')`:
990
+ `request('GET', 'secret/data/foo?version=2')`. These checks apply in every mode (passthrough,
991
+ `engines`, `autoDetect`). `request()` sends its path literally and does not apply them.
992
+
993
+ Still validate lower-trust input before building a path from it: these checks only stop a path
994
+ from reaching a different Vault path than the one it names, not a caller from naming another
995
+ tenant's secret.
996
+
997
+ A validation error's message says which rule failed but does not include the path, because the
998
+ KV methods log the message at `error` level.
999
+
944
1000
  ### Mount detection caching
945
1001
 
946
1002
  - 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.
947
1003
  - Concurrent first-touch requests for the same mount share a single in-flight detection promise.
1004
+ - 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`.
948
1005
  - The detection endpoint used is `GET sys/internal/ui/mounts/<path>` (readable by any authenticated token).
949
1006
  - When the token lacks permission on that endpoint, set `api.engines` to skip detection.
950
1007
 
@@ -970,10 +1027,10 @@ try {
970
1027
  | Class | Extends | When thrown |
971
1028
  |---|---|---|
972
1029
  | `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. |
973
- | `VaultHttpError` | `VaultError` | Vault answered with a non-2xx status. Carries the status as `statusCode` and the parsed body as `error`. |
974
- | `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 an invalid node-config substitution map. |
1030
+ | `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. |
1031
+ | `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). |
975
1032
  | `InvalidAWSCredentialsError` | `InvalidArgumentsError` | `auth.config.credentials` was supplied but is not a usable `accessKeyId` / `secretAccessKey` pair. |
976
- | `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. |
1033
+ | `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. |
977
1034
  | `UnsupportedOperationError` | `VaultError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) was called against a mount that did not resolve as KV v2. |
978
1035
 
979
1036
  ## Contributing
package/index.d.ts CHANGED
@@ -127,5 +127,6 @@ declare namespace VaultClient {
127
127
  isExpired(): boolean;
128
128
  isRenewable(): boolean;
129
129
  getExpiresAt(): number | null;
130
+ getTtlSeconds?(): number | null;
130
131
  }
131
132
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "2.3.0",
3
+ "version": "3.0.1",
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": ">=18.0.0"
9
+ "node": ">=20.19.0"
10
10
  },
11
11
  "main": "src/VaultClient.js",
12
12
  "types": "index.d.ts",
@@ -45,29 +45,29 @@
45
45
  "author": "Namecheap <opensource@namecheap.com>",
46
46
  "license": "Apache-2.0",
47
47
  "dependencies": {
48
- "@aws-sdk/credential-providers": "^3.1100.0",
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 <4"
53
+ "config": ">=1 <5"
54
54
  },
55
55
  "overrides": {
56
- "serialize-javascript": "^7.0.7",
56
+ "serialize-javascript": "^7.1.4",
57
57
  "glob": "^13.0.0",
58
58
  "diff": "^8.0.3",
59
- "brace-expansion": "^5.0.9",
59
+ "brace-expansion": "^5.0.12",
60
60
  "js-yaml": "^4.3.2"
61
61
  },
62
62
  "devDependencies": {
63
- "@eslint/js": "^9.39.5",
64
- "c8": "^10.1.3",
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": "^9.39.5",
67
+ "eslint": "^10.10.0",
68
68
  "globals": "^17.8.0",
69
69
  "lodash": "^4.18.1",
70
- "mocha": "^11.7.6",
70
+ "mocha": "^12.0.0",
71
71
  "sinon": "^22.1.0",
72
72
  "sinon-chai": "^4.0.1",
73
73
  "typescript": "^7.0.2"
@@ -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.replace(/\/+$/, ''), Number(version)]);
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 (path === prefix || path.startsWith(prefix + '/')) {
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 (path === entry.mount || path.startsWith(entry.mount + '/')) {
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
- const canonicalMount = (info.path || interimKey).replace(/\/+$/, '');
185
- const type = info.type || 'unknown';
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
- // Store in persistent cache (bounded LRU)
194
- this.__cacheStore(canonicalMount, entry);
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.message}. ` +
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
  */
@@ -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.replace(/\/+$/, '');
18
+ segment = trimTrailingSlashes(segment);
18
19
  }
19
20
  return segment;
20
21
  })
@@ -12,9 +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 { warnIfNode18 } = require('./checkNodeVersion');
16
-
17
- warnIfNode18();
15
+ const safeMessage = require('./safeMessage');
16
+ const { trimTrailingSlashes } = require('./slashes');
18
17
 
19
18
  const noop = () => {};
20
19
  const vaultInstances = {};
@@ -49,6 +48,74 @@ function optionsDigest(options) {
49
48
  return crypto.createHash('sha256').update(fingerprintOptions(options)).digest('hex');
50
49
  }
51
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
+
52
119
  class VaultClient {
53
120
 
54
121
  /**
@@ -130,6 +197,7 @@ class VaultClient {
130
197
 
131
198
  this.__resolver = new MountResolver(detectFn, engines, this.__log, {
132
199
  disabled: resolverDisabled,
200
+ namespace,
133
201
  });
134
202
  }
135
203
 
@@ -344,11 +412,22 @@ class VaultClient {
344
412
  * engine version (merge-patch update() semantics); by default only v2
345
413
  * write/update wrap non-null data and everything else passes through
346
414
  * @returns {Promise<{body: *, version: (number|undefined), mount: (string|undefined), apiPath: string}>}
415
+ * rejects with InvalidArgumentsError, before any request, for an unsafe KV path
347
416
  * @private
348
417
  */
349
418
  __resolveAndRequest(op, method, path, data, extraHeaders, options) {
350
419
  const { raw = false, v2Only = false, wrapData = false } = options || {};
351
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
+
352
431
  return this.__auth.getAuthToken()
353
432
  .then((token) => {
354
433
  const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
@@ -401,10 +480,17 @@ class VaultClient {
401
480
  * @private
402
481
  */
403
482
  __logicalPath(path, mount) {
404
- const prefix = mount.replace(/\/+$/, '');
483
+ const prefix = trimTrailingSlashes(mount);
405
484
  if (path === prefix) return '';
406
485
  if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
407
- return path;
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
+ );
408
494
  }
409
495
 
410
496
  // -------------------------------------------------------------------------
@@ -425,7 +511,7 @@ class VaultClient {
425
511
  return Lease.fromResponse(normalised);
426
512
  })
427
513
  .catch((reason) => {
428
- this.__log.error('read secret failed: %s', reason.message);
514
+ this.__log.error('read secret failed: %s', safeMessage(reason));
429
515
  throw reason;
430
516
  });
431
517
  }
@@ -445,7 +531,7 @@ class VaultClient {
445
531
  return Lease.fromResponse(normalised);
446
532
  })
447
533
  .catch((reason) => {
448
- this.__log.error('list secrets failed: %s', reason.message);
534
+ this.__log.error('list secrets failed: %s', safeMessage(reason));
449
535
  throw reason;
450
536
  });
451
537
  }
@@ -465,7 +551,7 @@ class VaultClient {
465
551
  return body;
466
552
  })
467
553
  .catch((reason) => {
468
- this.__log.error('write secret failed: %s', reason.message);
554
+ this.__log.error('write secret failed: %s', safeMessage(reason));
469
555
  throw reason;
470
556
  });
471
557
  }
@@ -487,7 +573,7 @@ class VaultClient {
487
573
  return body;
488
574
  })
489
575
  .catch((reason) => {
490
- this.__log.error('delete secret failed: %s', reason.message);
576
+ this.__log.error('delete secret failed: %s', safeMessage(reason));
491
577
  throw reason;
492
578
  });
493
579
  }
@@ -512,14 +598,15 @@ class VaultClient {
512
598
  { 'Content-Type': 'application/merge-patch+json' }, { wrapData: true })
513
599
  .then(({ body }) => body)
514
600
  .catch((reason) => {
515
- this.__log.error('update secret failed: %s', reason.message);
601
+ this.__log.error('update secret failed: %s', safeMessage(reason));
516
602
  throw reason;
517
603
  });
518
604
  }
519
605
 
520
606
  /**
521
607
  * Raw request — literal path, no path rewriting or response unwrapping.
522
- * Returns the parsed body directly.
608
+ * Returns the parsed body directly. The path is not validated: never build it from
609
+ * untrusted input.
523
610
  *
524
611
  * @param {string} method - HTTP method
525
612
  * @param {string} path - literal API path
@@ -531,7 +618,7 @@ class VaultClient {
531
618
  return this.__resolveAndRequest('request', method, path, data === undefined ? null : data, null, { raw: true })
532
619
  .then(({ body }) => body)
533
620
  .catch((reason) => {
534
- this.__log.error('raw request failed: %s', reason.message);
621
+ this.__log.error('raw request failed: %s', safeMessage(reason));
535
622
  throw reason;
536
623
  });
537
624
  }
@@ -552,7 +639,7 @@ class VaultClient {
552
639
  this.__log.debug('deleteVersions %s %j', path, versions);
553
640
  return this.__v2Only('deleteVersions', 'POST', path, { versions })
554
641
  .catch((reason) => {
555
- this.__log.error('deleteVersions failed: %s', reason.message);
642
+ this.__log.error('deleteVersions failed: %s', safeMessage(reason));
556
643
  throw reason;
557
644
  });
558
645
  }
@@ -569,7 +656,7 @@ class VaultClient {
569
656
  this.__log.debug('undeleteVersions %s %j', path, versions);
570
657
  return this.__v2Only('undeleteVersions', 'POST', path, { versions })
571
658
  .catch((reason) => {
572
- this.__log.error('undeleteVersions failed: %s', reason.message);
659
+ this.__log.error('undeleteVersions failed: %s', safeMessage(reason));
573
660
  throw reason;
574
661
  });
575
662
  }
@@ -586,7 +673,7 @@ class VaultClient {
586
673
  this.__log.debug('destroyVersions %s %j', path, versions);
587
674
  return this.__v2Only('destroyVersions', 'POST', path, { versions })
588
675
  .catch((reason) => {
589
- this.__log.error('destroyVersions failed: %s', reason.message);
676
+ this.__log.error('destroyVersions failed: %s', safeMessage(reason));
590
677
  throw reason;
591
678
  });
592
679
  }
@@ -605,7 +692,7 @@ class VaultClient {
605
692
  return normalizeResponse(2, 'readMetadata', body);
606
693
  })
607
694
  .catch((reason) => {
608
- this.__log.error('readMetadata failed: %s', reason.message);
695
+ this.__log.error('readMetadata failed: %s', safeMessage(reason));
609
696
  throw reason;
610
697
  });
611
698
  }
@@ -621,7 +708,7 @@ class VaultClient {
621
708
  this.__log.debug('deleteMetadata %s', path);
622
709
  return this.__v2Only('deleteMetadata', 'DELETE', path, null)
623
710
  .catch((reason) => {
624
- this.__log.error('deleteMetadata failed: %s', reason.message);
711
+ this.__log.error('deleteMetadata failed: %s', safeMessage(reason));
625
712
  throw reason;
626
713
  });
627
714
  }
@@ -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 = this.__nodeConfig.util.initParam('NODE_CONFIG_DIR', path.join(process.cwd(), 'config'));
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
  }
@@ -12,6 +12,10 @@ class AuthToken {
12
12
  * @param {int} explicitMaxTtl - in seconds
13
13
  * @param {int} numUses
14
14
  * @param {boolean} isRenewable
15
+ * @param {int|null} expiresAtMilliseconds - precise expiry time when available
16
+ * @param {int|null} ttl - remaining lifetime reported by Vault, when available
17
+ * @param {boolean} hasPreciseExpiry - true when expiry came from Vault's expire_time
18
+ * @param {int|null} unadjustedExpiresAtMilliseconds - Vault's expiry before the client safety margin
15
19
  */
16
20
  constructor(
17
21
  id,
@@ -20,35 +24,58 @@ class AuthToken {
20
24
  expiresAt,
21
25
  explicitMaxTtl,
22
26
  numUses,
23
- isRenewable
27
+ isRenewable,
28
+ expiresAtMilliseconds = null,
29
+ ttl = null,
30
+ hasPreciseExpiry = false,
31
+ unadjustedExpiresAtMilliseconds = null
24
32
  ) {
25
33
  this.__id = id;
26
34
  this.__accessor = accessor;
27
35
  this.__createdAt = createdAt;
28
- this.__expiresAt = expiresAt;
36
+ this.__expiresAtMilliseconds = expiresAt === null
37
+ ? expiresAtMilliseconds
38
+ : (expiresAtMilliseconds ?? expiresAt * 1000);
39
+ this.__expiresAt = this.__expiresAtMilliseconds === null
40
+ ? null
41
+ : Math.floor(this.__expiresAtMilliseconds / 1000);
42
+ this.__unadjustedExpiresAtMilliseconds = unadjustedExpiresAtMilliseconds
43
+ ?? this.__expiresAtMilliseconds;
44
+ this.__hasPreciseExpiry = hasPreciseExpiry;
45
+ this.__ttlSeconds = Number.isFinite(ttl) ? ttl : null;
29
46
  this.__explicitMaxTtl = explicitMaxTtl;
30
47
  this.__numUses = numUses;
31
- this.__isRenewable = isRenewable;
48
+ this.__isRenewable = isRenewable
49
+ && ttl !== 0
50
+ && this.__expiresAtMilliseconds !== null;
32
51
  }
33
52
 
34
53
  static fromResponse(response) {
35
54
  const data = response.data;
55
+ const unadjustedExpiresAtMilliseconds = AuthToken.__parseRfc3339Milliseconds(data.expire_time);
56
+ const hasPreciseExpiry = unadjustedExpiresAtMilliseconds !== null;
57
+ const expiresAtMilliseconds = AuthToken.__resolveExpiresAtMilliseconds(data, unadjustedExpiresAtMilliseconds);
36
58
 
37
59
  return new AuthToken(
38
60
  data.id,
39
61
  data.accessor,
40
62
  data.creation_time,
41
- AuthToken.__resolveExpiresAt(data),
63
+ null,
42
64
  data.explicit_max_ttl,
43
65
  data.num_uses,
44
- data.renewable !== undefined ? data.renewable : false
66
+ data.renewable !== undefined ? data.renewable : false,
67
+ expiresAtMilliseconds,
68
+ data.ttl,
69
+ hasPreciseExpiry,
70
+ unadjustedExpiresAtMilliseconds
45
71
  );
46
72
  }
47
73
 
48
74
  /**
49
- * Resolve the UNIX timestamp (seconds) at which the client should treat the token
50
- * as expired, shifted earlier by a network-latency safety margin so renewal happens
51
- * before the real expiry.
75
+ * Resolve the UNIX timestamp (milliseconds) at which the client should treat the token
76
+ * as expired. A zero TTL is expired when received because the reported TTL is truncated
77
+ * and Vault's wall clock may differ from the client's. For longer TTLs, keep the existing
78
+ * one-minute margin only when more than one minute remains.
52
79
  *
53
80
  * Prefers Vault's authoritative `expire_time` (RFC3339), which is the documented
54
81
  * absolute expiry. Falls back to `(last_renewal_time || creation_time) + ttl` for
@@ -57,37 +84,41 @@ class AuthToken {
57
84
  *
58
85
  * @private
59
86
  * @param {Object} data - the `data` object of a token lookup response
60
- * @returns {int|null} null when the token never expires (ttl === 0)
87
+ * @param {number|null} expireTime - parsed Vault expiry in milliseconds
88
+ * @returns {int|null} client expiry in milliseconds, or null when ttl is 0 without expire_time
61
89
  */
62
- static __resolveExpiresAt(data) {
63
- if (data.ttl === 0) {
64
- return null;
65
- }
66
-
90
+ static __resolveExpiresAtMilliseconds(data, expireTime) {
67
91
  const networkLatency = 60;
68
- const margin = data.ttl > networkLatency ? networkLatency : 0;
69
92
 
70
- const expireTime = AuthToken.__parseRfc3339Seconds(data.expire_time);
71
93
  if (expireTime !== null) {
72
- return expireTime - margin;
94
+ if (data.ttl === 0) {
95
+ return Date.now();
96
+ }
97
+
98
+ const margin = data.ttl > networkLatency ? networkLatency : 0;
99
+ return expireTime - margin * 1000;
100
+ }
101
+
102
+ if (data.ttl === 0) {
103
+ return null;
73
104
  }
74
105
 
75
106
  // Fallback for responses without a usable `expire_time`.
76
107
  const creationTime = parseInt(data.last_renewal_time !== undefined ? data.last_renewal_time : data.creation_time, 10);
77
108
  const ttl = data.ttl > networkLatency ? data.ttl - networkLatency : data.ttl;
78
- return creationTime + parseInt(ttl, 10);
109
+ return (creationTime + parseInt(ttl, 10)) * 1000;
79
110
  }
80
111
 
81
112
  /**
82
113
  * Parse an RFC3339 timestamp (as returned by Vault, up to nanosecond precision with
83
- * a timezone offset) into whole UNIX seconds. Returns null for missing, empty,
114
+ * a timezone offset) into UNIX milliseconds. Returns null for missing, empty,
84
115
  * unparseable, or non-positive (e.g. Vault's "0001-01-01T00:00:00Z" zero value) input.
85
116
  *
86
117
  * @private
87
118
  * @param {String} value
88
119
  * @returns {int|null}
89
120
  */
90
- static __parseRfc3339Seconds(value) {
121
+ static __parseRfc3339Milliseconds(value) {
91
122
  if (typeof value !== 'string' || value === '') {
92
123
  return null;
93
124
  }
@@ -97,8 +128,7 @@ class AuthToken {
97
128
  return null;
98
129
  }
99
130
 
100
- const seconds = Math.floor(ms / 1000);
101
- return seconds > 0 ? seconds : null;
131
+ return ms > 0 ? ms : null;
102
132
  }
103
133
 
104
134
  /**
@@ -120,6 +150,12 @@ class AuthToken {
120
150
  return false;
121
151
  }
122
152
 
153
+ if (this.__hasPreciseExpiry) {
154
+ return Date.now() >= this.__expiresAtMilliseconds;
155
+ }
156
+
157
+ // Preserve the previous whole-second boundary for tokens that do not carry
158
+ // Vault's authoritative expire_time.
123
159
  return Math.floor(Date.now() / 1000) > this.__expiresAt;
124
160
  }
125
161
 
@@ -127,7 +163,7 @@ class AuthToken {
127
163
  * @returns {boolean}
128
164
  */
129
165
  isRenewable() {
130
- return this.__isRenewable && this.getExpiresAt() !== null;
166
+ return this.__isRenewable;
131
167
  }
132
168
 
133
169
  /**
@@ -136,6 +172,35 @@ class AuthToken {
136
172
  getExpiresAt() {
137
173
  return this.__expiresAt;
138
174
  }
175
+
176
+ /**
177
+ * @returns {int|null} precise UNIX timestamp in milliseconds, when available
178
+ */
179
+ getExpiresAtMilliseconds() {
180
+ return this.__expiresAtMilliseconds;
181
+ }
182
+
183
+ /**
184
+ * @returns {int|null} expiry before the safety adjustment, when available
185
+ */
186
+ getUnadjustedExpiresAtMilliseconds() {
187
+ return this.__unadjustedExpiresAtMilliseconds;
188
+ }
189
+
190
+ /**
191
+ * Return Vault's whole-second TTL when this token came from a lookup response.
192
+ * @returns {number|null}
193
+ */
194
+ getTtlSeconds() {
195
+ return this.__ttlSeconds;
196
+ }
197
+
198
+ /**
199
+ * @returns {boolean}
200
+ */
201
+ hasPreciseExpiry() {
202
+ return this.__hasPreciseExpiry;
203
+ }
139
204
  }
140
205
 
141
206
  module.exports = AuthToken;
@@ -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: role_id=%s',
32
- this.__roleId
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`, {
@@ -9,7 +9,10 @@ const AuthToken = require('./AuthToken');
9
9
 
10
10
  /** Renew at the halfway point of the token's remaining lifetime. */
11
11
  const DEFAULT_RENEWAL_FRACTION = 0.5;
12
+ /** Maximum rounding difference allowed when comparing expiry movement with a renewal interval. */
13
+ const MAX_EXPIRY_JITTER_MS = 1000;
12
14
  const errors = require('../errors');
15
+ const safeMessage = require('../safeMessage');
13
16
 
14
17
  /**
15
18
  * Formats a millisecond duration as a short human-readable string
@@ -28,6 +31,81 @@ function humanizeMs(ms) {
28
31
  return `${hours}h ${totalMinutes % 60}m`;
29
32
  }
30
33
 
34
+ /**
35
+ * Read precise expiry when available, preserving the public AuthToken interface fallback.
36
+ * @param {AuthToken} authToken
37
+ * @returns {number|null}
38
+ * @private
39
+ */
40
+ function tokenExpiryMilliseconds(authToken) {
41
+ if (typeof authToken.getExpiresAtMilliseconds === 'function') {
42
+ const expiry = authToken.getExpiresAtMilliseconds();
43
+ if (Number.isFinite(expiry)) {
44
+ return expiry;
45
+ }
46
+ }
47
+
48
+ const expiry = authToken.getExpiresAt();
49
+ return typeof expiry === 'number' && Number.isFinite(expiry) ? expiry * 1000 : null;
50
+ }
51
+
52
+ /**
53
+ * Read Vault's unadjusted absolute expiry when available; duck-typed tokens fall back to their
54
+ * public expiry in seconds.
55
+ * @param {AuthToken} authToken
56
+ * @returns {number|null}
57
+ * @private
58
+ */
59
+ function tokenUnadjustedExpiryMilliseconds(authToken) {
60
+ if (typeof authToken.getUnadjustedExpiresAtMilliseconds === 'function') {
61
+ const expiry = authToken.getUnadjustedExpiresAtMilliseconds();
62
+ if (Number.isFinite(expiry)) {
63
+ return expiry;
64
+ }
65
+ }
66
+
67
+ return tokenExpiryMilliseconds(authToken);
68
+ }
69
+
70
+ /**
71
+ * Read Vault's whole-second TTL when the token implementation exposes it.
72
+ * @param {AuthToken} authToken
73
+ * @returns {number|null}
74
+ * @private
75
+ */
76
+ function tokenTtlSeconds(authToken) {
77
+ if (typeof authToken.getTtlSeconds !== 'function') {
78
+ return null;
79
+ }
80
+ const ttl = authToken.getTtlSeconds();
81
+ return typeof ttl === 'number' && Number.isFinite(ttl) ? ttl : null;
82
+ }
83
+
84
+ /**
85
+ * Explain why background renewal ended without exposing the token ID.
86
+ * @param {VaultBaseAuth} auth
87
+ * @param {AuthToken} authToken
88
+ * @param {string} reason
89
+ * @returns {void}
90
+ * @private
91
+ */
92
+ function warnRenewalStopped(auth, authToken, reason) {
93
+ const expiry = tokenExpiryMilliseconds(authToken);
94
+ const nextAction = expiry === null
95
+ ? 'expiry is unknown; the cached token remains in use until it is replaced'
96
+ : auth._reauthenticationAllowed()
97
+ ? 'the next call after expiry will re-authenticate'
98
+ : 'the next call after expiry will fail with AuthTokenExpiredError';
99
+
100
+ auth._log.warn(
101
+ 'token renewal stopped (accessor=%s): %s; client-side expiry is %s; %s',
102
+ authToken.getAccessor(),
103
+ reason,
104
+ expiry === null ? 'unknown' : new Date(expiry).toISOString(),
105
+ nextAction
106
+ );
107
+ }
108
+
31
109
  class VaultBaseAuth {
32
110
 
33
111
  /**
@@ -254,12 +332,38 @@ class VaultBaseAuth {
254
332
  this.__refreshTimeout = null;
255
333
  }
256
334
 
257
- if (!this.__renewalEnabled || !authToken.isRenewable() || authToken.isExpired()) {
335
+ if (!this.__renewalEnabled) {
336
+ return;
337
+ }
338
+ if (!authToken.isRenewable() || authToken.isExpired()) {
339
+ warnRenewalStopped(
340
+ this,
341
+ authToken,
342
+ authToken.isExpired() ? 'token already expired' : 'token is not renewable'
343
+ );
258
344
  return;
259
345
  }
260
346
 
261
- const remaining = authToken.getExpiresAt() - Math.floor(Date.now() / 1000);
262
- const timer = Math.max(remaining * this.__renewalFraction, 1) * 1000;
347
+ const expiry = tokenExpiryMilliseconds(authToken);
348
+ if (expiry === null) {
349
+ warnRenewalStopped(this, authToken, 'token has no usable expiry');
350
+ return;
351
+ }
352
+
353
+ const remaining = expiry - Date.now();
354
+ if (remaining <= 0) {
355
+ warnRenewalStopped(this, authToken, 'token already expired');
356
+ return;
357
+ }
358
+
359
+ const timer = Math.max(
360
+ Math.floor(remaining * this.__renewalFraction),
361
+ 250
362
+ );
363
+ if (timer >= remaining) {
364
+ warnRenewalStopped(this, authToken, 'too little lifetime left to renew');
365
+ return;
366
+ }
263
367
 
264
368
  // A renewal that is still in flight when the token is replaced or cancelled must not
265
369
  // act on what it finds when it lands: it would overwrite the newer token, or clear
@@ -267,26 +371,68 @@ class VaultBaseAuth {
267
371
  // permanently un-renewed. Anything that supersedes this timer bumps the epoch.
268
372
  const epoch = ++this.__renewalEpoch;
269
373
  const superseded = () => this.__renewalEpoch !== epoch;
374
+ const scheduledAt = Date.now();
270
375
 
271
376
  this.__refreshTimeout = lt.setTimeout(() => {
272
377
  if (superseded()) {
273
378
  return;
274
379
  }
380
+ this.__refreshTimeout = null;
275
381
 
276
382
  this.__renewToken(authToken).then(renewedToken => {
277
383
  if (superseded()) {
278
384
  return;
279
385
  }
280
386
  this.__authToken = renewedToken;
281
- this.__setupTokenRefreshTimer(renewedToken);
387
+ if (!renewedToken.isRenewable() || renewedToken.isExpired()) {
388
+ this.__setupTokenRefreshTimer(renewedToken);
389
+ return;
390
+ }
391
+
392
+ const previousExpiry = tokenUnadjustedExpiryMilliseconds(authToken);
393
+ const renewedExpiry = tokenUnadjustedExpiryMilliseconds(renewedToken);
394
+ const expiryMovement = previousExpiry === null || renewedExpiry === null
395
+ ? null
396
+ : renewedExpiry - previousExpiry;
397
+ const previousTtl = tokenTtlSeconds(authToken);
398
+ const renewedTtl = tokenTtlSeconds(renewedToken);
399
+ const ttlDidNotDecrease = previousTtl === null || renewedTtl === null
400
+ || renewedTtl >= previousTtl;
401
+ const renewalElapsedMs = Date.now() - scheduledAt;
402
+ // A max-TTL cap leaves the absolute expiry roughly fixed, while a periodic
403
+ // renewal moves it forward by about the time spent waiting. Compare the
404
+ // reported TTL too, so whole-second rounding does not make a short extension
405
+ // look like a capped token.
406
+ const expiryAdvancedWithRenewal = expiryMovement !== null
407
+ && expiryMovement >= 0
408
+ && ttlDidNotDecrease
409
+ && Math.abs(expiryMovement - renewalElapsedMs) <= MAX_EXPIRY_JITTER_MS;
410
+ const expiryMovedMaterially = expiryMovement !== null
411
+ && Math.abs(expiryMovement) > MAX_EXPIRY_JITTER_MS;
412
+
413
+ if (expiryMovedMaterially || expiryAdvancedWithRenewal) {
414
+ this.__setupTokenRefreshTimer(renewedToken);
415
+ } else {
416
+ warnRenewalStopped(
417
+ this,
418
+ renewedToken,
419
+ 'Vault did not extend the token (maximum TTL likely reached)'
420
+ );
421
+ }
282
422
  }).catch(err => {
283
423
  if (superseded()) {
284
424
  return;
285
425
  }
286
426
  this.__setupTokenRefreshTimer(authToken);
287
427
 
288
- this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${err.message}`);
289
- this._log.error(err);
428
+ const message = `Cannot refresh auth token with "${authToken.getAccessor()}" accessor.`;
429
+ // Dumping a VaultHttpError would print its raw response body, so it gets the safe
430
+ // message only. Any other error is logged whole: its stack already has the message.
431
+ if (err instanceof errors.VaultHttpError) {
432
+ this._log.error(`${message} Error: ${safeMessage(err)}`);
433
+ } else {
434
+ this._log.error(message, err);
435
+ }
290
436
  });
291
437
  }, timer);
292
438
 
@@ -313,7 +459,7 @@ class VaultBaseAuth {
313
459
  return this._getTokenEntity(authToken.getId());
314
460
  })
315
461
  .catch((reason) => {
316
- this._log.error('token renew failed: %s', reason.message);
462
+ this._log.error('token renew failed: %s', safeMessage(reason));
317
463
  throw reason;
318
464
  });
319
465
  }
@@ -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.replace(/\/+$/, '');
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 };
@@ -1,27 +0,0 @@
1
- 'use strict';
2
-
3
- const NODE_18_DEPRECATION_MESSAGE =
4
- 'node-vault-client: this is the last release of this package to support Node.js 18. ' +
5
- 'Node 18 reached end-of-life on 2025-04-30 and no longer receives security patches from ' +
6
- 'the Node.js project. The next major release of node-vault-client will require ' +
7
- 'Node.js >= 20.19.0 — see CHANGELOG.md for details.';
8
- const NODE_18_DEPRECATION_CODE = 'NodeVaultClientNode18Deprecation';
9
-
10
- /**
11
- * Emits a one-time process warning when running on Node.js 18. Called once, at module load,
12
- * from VaultClient.js — require()'s own module cache is what keeps it to once per process,
13
- * not any dedup logic here.
14
- *
15
- * Takes a `process`-like object (defaulting to the real `process`) purely so tests can assert
16
- * the behaviour on a stubbed Node version instead of on whatever Node this suite happens to run
17
- * under.
18
- */
19
- function warnIfNode18(proc) {
20
- proc = proc || process;
21
- const nodeVersion = proc.versions && proc.versions.node;
22
- if (typeof nodeVersion === 'string' && /^18\./.test(nodeVersion)) {
23
- proc.emitWarning(NODE_18_DEPRECATION_MESSAGE, { code: NODE_18_DEPRECATION_CODE });
24
- }
25
- }
26
-
27
- module.exports = { warnIfNode18, NODE_18_DEPRECATION_MESSAGE, NODE_18_DEPRECATION_CODE };