node-vault-client 1.0.2 → 1.0.4

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
@@ -29,6 +29,153 @@ vaultClient.read('secret/tst').then(v => {
29
29
  }).catch(e => console.error(e));
30
30
  ```
31
31
 
32
+ ## KV v2 support
33
+
34
+ This release adds transparent support for the [KV v2 secrets engine](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2)
35
+ on top of the existing KV v1 / raw passthrough behaviour.
36
+
37
+ **It is fully opt-in and non-breaking.** With no extra configuration the client behaves exactly as
38
+ before: every path is treated as KV v1 / raw passthrough and no extra request is made. KV v2 is
39
+ activated per mount by setting `api.kv.autoDetect: true` **or** by supplying a static `api.engines`
40
+ map. Once enabled, the client rewrites paths (`secret/foo` → `secret/data/foo`) and unwraps the
41
+ nested KV v2 response automatically — callers never deal with the `data/` / `metadata/` segments
42
+ themselves.
43
+
44
+ ### Configuration options
45
+
46
+ | Option | Type | Default | Description |
47
+ |---|---|---|---|
48
+ | `api.kv.autoDetect` | `boolean` | `false` | Auto-detect the KV version of each mount on first use via `GET sys/internal/ui/mounts/<path>`. The result is cached per mount for the lifetime of the client. |
49
+ | `api.engines` | `Object` | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Listed mounts resolve with no detection round-trip; use this when the token lacks permission on `sys/internal/ui/mounts`. |
50
+
51
+ `engines` always takes precedence over detection, so the two can be combined: matching mounts use
52
+ the map while unmatched mounts are auto-detected (when `autoDetect: true`) or treated as v1.
53
+
54
+ ### KV v1 (default — nothing to configure)
55
+
56
+ ```javascript
57
+ const client = VaultClient.boot('main', {
58
+ api: { url: 'https://vault.example.com:8200/' },
59
+ auth: { type: 'token', config: { token: '...' } },
60
+ });
61
+
62
+ const lease = await client.read('secret/my-app/config'); // GET secret/my-app/config
63
+ console.log(lease.getData()); // the secret object
64
+ console.log(lease.getMetadata()); // undefined on KV v1
65
+ ```
66
+
67
+ ### KV v2
68
+
69
+ Pass **logical** paths only — the client inserts the `data/` (and `metadata/` for `list`) segment
70
+ for you.
71
+
72
+ ```javascript
73
+ const client = VaultClient.boot('main', {
74
+ api: {
75
+ url: 'https://vault.example.com:8200/',
76
+ engines: { secret: 2 }, // or: kv: { autoDetect: true }
77
+ },
78
+ auth: { type: 'token', config: { token: '...' } },
79
+ });
80
+
81
+ // read → GET secret/data/my-app/config, response.data.data is unwrapped automatically
82
+ const lease = await client.read('secret/my-app/config');
83
+ console.log(lease.getData()); // the secret values
84
+ console.log(lease.getMetadata()); // { version, created_time, ... } on KV v2
85
+
86
+ // write → POST secret/data/my-app/config with { data: { ... } } wrapping done for you
87
+ await client.write('secret/my-app/config', { password: 's3cr3t' });
88
+
89
+ // list → LIST secret/metadata/my-app
90
+ await client.list('secret/my-app');
91
+ ```
92
+
93
+ ### Mixed v1 + v2 mounts on one client
94
+
95
+ Version resolution is **per mount**, so a single client can talk to v1 and v2 mounts at the same
96
+ time. List each mount's version in `api.engines`:
97
+
98
+ ```javascript
99
+ const client = VaultClient.boot('main', {
100
+ api: {
101
+ url: 'https://vault.example.com:8200/',
102
+ engines: {
103
+ 'secret-v2': 2, // KV v2 mount
104
+ // 'secret' is not listed → treated as KV v1 (passthrough)
105
+ },
106
+ },
107
+ auth: { type: 'token', config: { token: '...' } },
108
+ });
109
+
110
+ await client.read('secret/legacy/app'); // v1 → GET secret/legacy/app
111
+ await client.read('secret-v2/app/config'); // v2 → GET secret-v2/data/app/config
112
+ await client.read('database/creds/app-role'); // not KV → passthrough, GET as-is
113
+ ```
114
+
115
+ Or let the client figure it out with `autoDetect` (requires the token to read
116
+ `sys/internal/ui/mounts`):
117
+
118
+ ```javascript
119
+ const client = VaultClient.boot('main', {
120
+ api: { url: 'https://vault.example.com:8200/', kv: { autoDetect: true } },
121
+ auth: { type: 'token', config: { token: '...' } },
122
+ });
123
+
124
+ await client.read('secret/legacy/app'); // detected as v1
125
+ await client.read('secret-v2/app/config'); // detected as v2
126
+ ```
127
+
128
+ ### Notes & caveats
129
+
130
+ - **Do not include KV v2 segments in the path.** Pass `secret/foo`, not `secret/data/foo` — the
131
+ latter would be rewritten to `secret/data/data/foo` on a v2 mount.
132
+ - **Vault policy must allow the v2 paths.** KV v2 reads hit `<mount>/data/...` (and `list` hits
133
+ `<mount>/metadata/...`), so the auth token's policy must grant capabilities on those paths, not
134
+ on the logical path.
135
+ - Non-KV backends (database, transit, etc.) are always treated as passthrough — leave them out of
136
+ `api.engines`, and `autoDetect` recognises them as non-KV automatically.
137
+ - If detection fails (e.g. permission denied) and no `api.engines` override matches, the read
138
+ rejects with a `VaultError` — set `api.engines` to bypass detection in that case.
139
+ - A detection answer without a string `path` and `type` fails the call and is not cached, and an
140
+ answer whose mount does not contain the requested path is rejected for that caller only. Auth
141
+ mounts are cached as `auth/<mount>`, so they never collide with a secrets mount of the same name.
142
+
143
+ ## Path validation
144
+
145
+ Since 1.0.4, `read`, `list` and `write` reject a path with `InvalidArgumentsError`, before any
146
+ request is sent (including the login and mount detection), when Vault or the HTTP stack would
147
+ resolve it to a different path than the one passed (GHSA-rr65-m54w-rfr9, GHSA-wf8v-q2h3-hgjm):
148
+
149
+ - an empty path, or one that is not a string;
150
+ - `?`, `#`, `\`, a control character (U+0000–U+001F, U+007F), an unpaired surrogate or a
151
+ trailing space;
152
+ - a percent-encoded `.`, `/`, `\` or `%` (`%2e`, `%2f`, `%5c`, `%25`, in any case);
153
+ - an empty segment such as `a//b`, which the client used to collapse into `a/b` before sending, so
154
+ `tenants/${id}/key` with an empty `id` addressed `tenants/key`;
155
+ - a `.` or `..` segment, which Vault redirects to the cleaned path and the client followed with its
156
+ token;
157
+ - a percent-encoded mount (`secre%74/foo`, or `team/k%76/foo` for the mount `team/kv`).
158
+
159
+ Leading slashes are dropped before the mount is resolved, so `/secret/foo` and `secret/foo`
160
+ address the same key. One trailing slash is kept, as in `list('secret/apps/')`. A key name that
161
+ needs `?` or `#` can still be addressed percent-encoded (`%3F`, `%23`).
162
+
163
+ These checks stop a path from reaching a different Vault path than the one it names; they do not
164
+ stop a caller from naming another tenant's key, so keep validating lower-trust input that goes
165
+ into a path. A validation error's message says which rule failed but not the path.
166
+
167
+ ## Logging
168
+
169
+ The default logger writes `error`, `warn`, `info` and `trace` to `console` and discards `debug`. A
170
+ custom logger must implement all five methods; one missing any of them is silently replaced by the
171
+ default logger. Since 1.0.4 no log line, at any level, carries a Vault token, the AppRole
172
+ `role_id`/`secret_id`, the Kubernetes JWT or a Vault response body: request logs name the method
173
+ and URL, auth logs name the token accessor, and failure logs keep only the HTTP status and Vault's
174
+ `errors` strings. Errors the client throws still carry the request in `err.options` (including the
175
+ `X-Vault-Token` header) and the response in `err.response`, but these are no longer printed when
176
+ the error is logged or serialised; the `StatusCodeError` message is still `"<status> - <body>"`, so
177
+ log `err.statusCode` and `err.error.errors` rather than the message if the body may hold secrets.
178
+
32
179
  ## Supported Auth Backends
33
180
 
34
181
  * [AWS IAM](https://www.vaultproject.io/docs/auth/aws.html#iam-auth-method)
@@ -65,10 +212,13 @@ Client constructor function.
65
212
  | options.api | <code>Object</code> | | |
66
213
  | options.api.url | <code>String</code> | | the url of the vault server |
67
214
  | [options.api.apiVersion] | <code>String</code> | `v1` | |
215
+ | [options.api.kv] | <code>Object</code> | | KV engine options |
216
+ | [options.api.kv.autoDetect] | <code>boolean</code> | `false` | Auto-detect the KV version per mount via `GET sys/internal/ui/mounts/<path>`. |
217
+ | [options.api.engines] | <code>Object</code> | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Overrides detection. |
68
218
  | options.auth | <code>Object</code> | | |
69
219
  | options.auth.type | <code>String</code> | | |
70
220
  | options.auth.config | <code>Object</code> | | auth configuration variables |
71
- | options.logger | <code>Object</code> | `false` | | Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging. |
221
+ | [options.logger] | <code>Object</code> \| <code>false</code> | | Logger that implements all five of "error", "warn", "info", "debug" and "trace"; one missing any of them is silently replaced by the default logger. The default logger writes to `console`, except `debug`, which is discarded. Pass `false` to disable logging. See [Logging](#logging). |
72
222
 
73
223
  #### vaultClient.fillNodeConfig()
74
224
  Populates Vault's values to NPM "config" module
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-vault-client",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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"
package/src/Lease.js CHANGED
@@ -8,13 +8,15 @@ class Lease {
8
8
  leaseId,
9
9
  leaseDuration,
10
10
  renewable,
11
- data
11
+ data,
12
+ metadata
12
13
  ) {
13
14
  this.__requestId = requestId;
14
15
  this.__leaseId = leaseId;
15
16
  this.__leaseDuration = leaseDuration;
16
17
  this.__renewable = renewable;
17
- this.__data = data === undefined ? {} : data;
18
+ this.__data = (data === undefined || data === null) ? {} : data;
19
+ this.__metadata = metadata;
18
20
  }
19
21
 
20
22
  static fromResponse(response) {
@@ -23,7 +25,8 @@ class Lease {
23
25
  response.lease_id,
24
26
  response.lease_duration,
25
27
  response.renewable,
26
- response.data
28
+ response.data,
29
+ response.metadata
27
30
  );
28
31
  }
29
32
 
@@ -52,6 +55,16 @@ class Lease {
52
55
  isRenewable() {
53
56
  return this.__renewable;
54
57
  }
58
+
59
+ /**
60
+ * KV v2 version metadata object (created_time, version, etc.).
61
+ * Returns undefined for KV v1 / non-KV leases.
62
+ *
63
+ * @returns {Object|undefined}
64
+ */
65
+ getMetadata() {
66
+ return this.__metadata;
67
+ }
55
68
  }
56
69
 
57
70
  module.exports = Lease;
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ const { VaultError, InvalidArgumentsError } = require('./errors');
4
+ const safeMessage = require('./safeMessage');
5
+ const { trimTrailingSlashes, trimSlashes } = require('./slashes');
6
+
7
+ /** True when `mount` is `path` itself or a whole-segment prefix of it. */
8
+ function containsPath(mount, path) {
9
+ return mount !== '' && (path === mount || path.startsWith(mount + '/'));
10
+ }
11
+
12
+ /**
13
+ * Per GHSA-rr65/GHSA-wf8v a mount must be named literally: Vault decodes the path, so
14
+ * 'team/k%76/x' would reach the 'team/kv' mount while matching it neither in the engines map
15
+ * nor as a prefix, and the KV v2 rewrite would be skipped or built on the wrong mount.
16
+ */
17
+ function encodedMountError(mount) {
18
+ return new InvalidArgumentsError(
19
+ `Invalid secret path: it does not start with the mount "${mount}" it resolved to; ` +
20
+ 'do not percent-encode the mount'
21
+ );
22
+ }
23
+
24
+ // '%2f' and '%25' never get here (VaultClient rejects them), so one decode is what Vault sees.
25
+ function decodePath(path) {
26
+ try {
27
+ return decodeURIComponent(path);
28
+ } catch (e) {
29
+ return path;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Resolves the KV engine version for a given secret path.
35
+ *
36
+ * Responsibilities:
37
+ * 1. Check engines override map first (longest-prefix match, no I/O).
38
+ * 2. Auto-detect via detectFn(path) -> { data: { path, type, options } }.
39
+ * 3. Cache by canonical mount path; de-duplicate in-flight detections.
40
+ * 4. On failure, throw VaultError with actionable guidance.
41
+ *
42
+ * @param {Function} detectFn - async (path: string) => Vault mount-info response
43
+ * @param {Object} enginesOverride - { [mountPrefix]: version }
44
+ * @param {Object} logger - logger with .debug(), .error() etc.
45
+ * @param {Object} [opts] - additional options
46
+ * @param {boolean} [opts.disabled] - if true, always return passthrough (version 1)
47
+ * @param {string} [opts.namespace] - the client's Vault namespace (X-Vault-Namespace)
48
+ */
49
+ class MountResolver {
50
+ constructor(detectFn, enginesOverride, logger, opts) {
51
+ this.__detectFn = detectFn;
52
+ // Leading slashes go too: VaultClient drops them from the path before it gets here.
53
+ this.__engines = Object.entries(enginesOverride || {})
54
+ .sort((a, b) => b[0].length - a[0].length)
55
+ .map(([prefix, version]) => [trimSlashes(prefix), Number(version)]);
56
+ this.__log = logger;
57
+ this.__disabled = opts && opts.disabled === true;
58
+ // Vault reports a mount in a child namespace with its full namespace path
59
+ // ("ns1/ns2/secret/"), while the caller's path is relative to this namespace.
60
+ const namespace = opts && typeof opts.namespace === 'string' ? trimSlashes(opts.namespace) : '';
61
+ this.__namespacePrefix = namespace ? `${namespace}/` : '';
62
+
63
+ // Cache: canonical mount path (no trailing slash) -> { mount, version, type }
64
+ this.__cache = new Map();
65
+ // In-flight promises: canonical mount path -> Promise<{mount,version,type}>
66
+ this.__inflight = new Map();
67
+ }
68
+
69
+ /**
70
+ * Resolve the engine version for the given path.
71
+ *
72
+ * @param {string} path - full logical path (e.g. "secret/foo/bar")
73
+ * @returns {Promise<{mount: string, version: number, type: string}>}
74
+ */
75
+ resolve(path) {
76
+ // 1. Check engines override (longest-prefix match). This applies even when
77
+ // auto-detection is disabled, so listed mounts resolve with no I/O.
78
+ const override = this.__enginesOverrideLookup(path);
79
+ const decodedOverride = this.__enginesOverrideLookup(decodePath(path));
80
+ if (decodedOverride !== null && (override === null || override.mount !== decodedOverride.mount)) {
81
+ return Promise.reject(encodedMountError(decodedOverride.mount));
82
+ }
83
+ if (override !== null) {
84
+ this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
85
+ return Promise.resolve(override);
86
+ }
87
+
88
+ // 2. When detection is disabled (autoDetect off), an unlisted mount is a
89
+ // passthrough (v1) — never issue a sys/internal/ui/mounts round-trip.
90
+ // engines is the documented fallback for Vaults that deny detection.
91
+ if (this.__disabled) {
92
+ return Promise.resolve({ mount: this.__extractMount(path), version: 1, type: 'kv' });
93
+ }
94
+
95
+ // 3. Auto-detect: use canonical mount path as cache key once we know it.
96
+ // For the in-flight grouping key we use the first path segment so that
97
+ // concurrent reads of sub-paths of the SAME mount (e.g. secret/a and
98
+ // secret/b) share one detection. After the shared promise resolves we
99
+ // verify that the canonical mount returned actually prefixes this path;
100
+ // if it does not (two distinct multi-segment mounts share a first segment,
101
+ // e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
102
+ // full path as key so each mount gets its own call.
103
+ // Each caller checks the result against its own path, so one caller's bad path
104
+ // cannot fail others sharing the detection.
105
+ const interimKey = path.split('/')[0];
106
+ return this.__detectWithCache(interimKey, path).then((entry) => {
107
+ if (!containsPath(entry.mount, path)) {
108
+ throw encodedMountError(entry.mount);
109
+ }
110
+ return entry;
111
+ });
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // Private helpers
116
+ // -------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Longest-prefix match against the engines override map.
120
+ * Returns { mount, version } or null if no match found.
121
+ */
122
+ __enginesOverrideLookup(path) {
123
+ for (const [prefix, version] of this.__engines) {
124
+ if (containsPath(prefix, path)) {
125
+ return { mount: prefix, version, type: 'kv' };
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Resolve via cache or detectFn. Groups concurrent calls by interimKey so
133
+ * only one detectFn call is in-flight per first-segment at a time.
134
+ *
135
+ * Followers that join a shared in-flight promise verify that the resolved
136
+ * canonical mount actually prefixes their path. If it does not (two distinct
137
+ * multi-segment mounts share a first segment, e.g. "team/kvA" and "team/kvB"),
138
+ * the follower falls through to start its own fresh detection keyed on its
139
+ * full path, so each mount gets its own accurate detection result.
140
+ */
141
+ __detectWithCache(interimKey, path) {
142
+ // Check persistent cache first (keyed on canonical mount path)
143
+ for (const [mountKey, entry] of this.__cache) {
144
+ if (containsPath(mountKey, path)) {
145
+ return Promise.resolve(entry);
146
+ }
147
+ }
148
+
149
+ // Check in-flight for this interim key (first segment or full path)
150
+ if (this.__inflight.has(interimKey)) {
151
+ // Join the existing in-flight promise but verify the result matches our path.
152
+ // If the detection was for a different sub-mount, start a fresh detection.
153
+ return this.__inflight.get(interimKey).then((entry) => {
154
+ if (containsPath(entry.mount, path)) {
155
+ return entry;
156
+ }
157
+ // The shared detection resolved to a different mount — start our own.
158
+ // Use the full path as key so it won't collide with the first-segment key.
159
+ return this.__detectWithCache(path, path);
160
+ });
161
+ }
162
+
163
+ // Start a new detection
164
+ const promise = this.__detectFn(path)
165
+ .then((response) => {
166
+ const info = response && response.data;
167
+ if (!info) {
168
+ throw new Error('Unexpected empty detection response');
169
+ }
170
+
171
+ // Require a mounts-endpoint shape (string path and type) and never fall back
172
+ // to a caller-derived key or a default type (GHSA-wf8v-q2h3-hgjm). This does not
173
+ // authenticate the response: a planted secret could carry the same fields. What
174
+ // keeps the detection request on the mounts endpoint is the path validation in
175
+ // VaultClient; this only stops a malformed answer from becoming routing.
176
+ if (typeof info.path !== 'string' || typeof info.type !== 'string' || !trimTrailingSlashes(info.path)) {
177
+ throw new Error('Unexpected detection response shape (missing string path/type)');
178
+ }
179
+ const canonicalMount = this.__canonicalMount(info.path, path);
180
+ const type = info.type;
181
+ const optVersion = info.options && info.options.version;
182
+ // Only kv v2 gets special treatment; everything else is passthrough
183
+ const version = (type === 'kv' && Number(optVersion) === 2) ? 2 : 1;
184
+
185
+ const entry = { mount: canonicalMount, version, type };
186
+ this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
187
+
188
+ // Cache only an answer that contains the path it was asked for; resolve()
189
+ // rejects a caller whose path the mount does not contain.
190
+ if (containsPath(canonicalMount, path)) {
191
+ this.__cache.set(canonicalMount, entry);
192
+ }
193
+ // Clear in-flight
194
+ this.__inflight.delete(interimKey);
195
+ return entry;
196
+ })
197
+ .catch((err) => {
198
+ // Clear in-flight so callers can retry
199
+ this.__inflight.delete(interimKey);
200
+ // safeMessage: this message is logged by the operation's catch handler, so it
201
+ // must not embed a raw response body (GHSA-2vr8-g4vw-7xpm).
202
+ throw new VaultError(
203
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
204
+ 'Set api.engines or disable autoDetect to bypass detection.'
205
+ );
206
+ });
207
+
208
+ this.__inflight.set(interimKey, promise);
209
+ return promise;
210
+ }
211
+
212
+ /**
213
+ * The cache key for a detected mount. For auth mounts Vault reports the path without the
214
+ * reserved "auth/" prefix (auth/apps/role/r -> "apps/"), so it is re-added and the entry
215
+ * cannot collide with a secrets mount of the same name. A mount in a child namespace is
216
+ * reported with the client's namespace in front; that prefix is dropped when only the
217
+ * shorter form contains the path.
218
+ */
219
+ __canonicalMount(reportedPath, path) {
220
+ const withTable = (mount) => (path.startsWith('auth/') ? `auth/${mount}` : mount);
221
+ const reported = trimTrailingSlashes(reportedPath);
222
+ const mount = withTable(reported);
223
+ if (!containsPath(mount, path) && this.__namespacePrefix && reported.startsWith(this.__namespacePrefix)) {
224
+ const relative = withTable(reported.slice(this.__namespacePrefix.length));
225
+ if (containsPath(relative, path)) {
226
+ return relative;
227
+ }
228
+ }
229
+ return mount;
230
+ }
231
+
232
+ /**
233
+ * Simple first-segment extraction used only for the disabled path.
234
+ */
235
+ __extractMount(path) {
236
+ return path.split('/')[0];
237
+ }
238
+ }
239
+
240
+ module.exports = MountResolver;
@@ -4,6 +4,27 @@ const rp = require('request-promise');
4
4
  const urljoin = require('url-join');
5
5
  const _ = require('lodash');
6
6
 
7
+ /**
8
+ * request-promise errors keep the request `options` (the `X-Vault-Token` header and the request
9
+ * body: written secrets, AppRole credentials, the Kubernetes JWT) and the `response`, whose
10
+ * request carries the same headers. Printing such an error (console.error(err), an error tracker,
11
+ * JSON.stringify) printed the token. Keep both properties readable, but non-enumerable, so they
12
+ * no longer show up when the error is inspected or serialised.
13
+ *
14
+ * @param {*} err
15
+ * @returns {*} the same error
16
+ */
17
+ function hideRequestDetails(err) {
18
+ if (err && typeof err === 'object') {
19
+ ['options', 'response'].forEach((key) => {
20
+ if (Object.prototype.hasOwnProperty.call(err, key)) {
21
+ Object.defineProperty(err, key, { enumerable: false });
22
+ }
23
+ });
24
+ }
25
+ return err;
26
+ }
27
+
7
28
  class VaultApiClient {
8
29
 
9
30
  /**
@@ -42,12 +63,15 @@ class VaultApiClient {
42
63
 
43
64
  return rp(requestOptions)
44
65
  .then((response) => {
45
- this._logger.debug('%s %s response body:\n%s',
66
+ // Do not log the response body: Vault responses carry secret material (auth
67
+ // tokens, secret reads). See issue #204.
68
+ this._logger.debug('%s %s -> response received',
46
69
  requestOptions.method,
47
- requestOptions.uri,
48
- JSON.stringify(response, null, ' ')
70
+ requestOptions.uri
49
71
  );
50
72
  return response;
73
+ }, (err) => {
74
+ throw hideRequestDetails(err);
51
75
  });
52
76
  }
53
77
  }
@@ -9,8 +9,72 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
9
9
  const VaultIAMAuth = require('./auth/VaultIAMAuth');
10
10
  const VaultNodeConfig = require('./VaultNodeConfig');
11
11
  const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
12
+ const MountResolver = require('./MountResolver');
13
+ const { rewritePath, normalizeResponse } = require('./kvTransform');
14
+ const safeMessage = require('./safeMessage');
15
+ const { trimTrailingSlashes } = require('./slashes');
12
16
  const vaultInstances = {};
13
17
 
18
+ // The URL parser and Vault reinterpret these: '?' starts the query, '#' the fragment, '\' is a
19
+ // path separator for http(s), and tab/LF/CR and trailing C0 controls or spaces are dropped. Any
20
+ // of them lets a KV path address a different key.
21
+ // eslint-disable-next-line no-control-regex
22
+ const UNSAFE_KV_PATH_CHARS = /[?#\\\u0000-\u001f\u007f]/;
23
+ // An unpaired surrogate (String.prototype.isWellFormed() needs Node 20; this line supports 14+).
24
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/;
25
+
26
+ /**
27
+ * Rejects a KV method path that the URL parser or Vault would resolve to a path other than
28
+ * `<mount>/<kv segment>/<logical path>` (GHSA-rr65-m54w-rfr9, GHSA-wf8v-q2h3-hgjm).
29
+ *
30
+ * Vault removes '.' and '..' segments and redirects, which `request` follows with the token, and
31
+ * url-join collapses an empty segment ('tenants//key' becomes 'tenants/key') before the request
32
+ * is sent. Encoded forms are rejected rather than decoded: Vault decodes %2e and %2f before it
33
+ * resolves '..', and Vault 2.x decodes again on every redirect hop, so a %25-encoded escape
34
+ * becomes %2e or %2f on the first hop and a real dot segment or separator on the second. No other
35
+ * escape can form a dot segment or a separator, so the segments are then checked as written.
36
+ *
37
+ * The messages never include the path: the KV methods log them at error level, and the path
38
+ * may be built from user data that was never logged before.
39
+ *
40
+ * The mount (first segment) must not be percent-encoded: the engines override and mount
41
+ * detection match the path as written, but Vault decodes it.
42
+ *
43
+ * @param {*} path - caller-supplied secret path
44
+ * @returns {string} the path without leading slashes; resolving the mount on it keeps the KV v2
45
+ * rewrite from taking '/secret/foo' for a key in 'secret/'
46
+ * @throws {InvalidArgumentsError}
47
+ * @private
48
+ */
49
+ function assertSafeKvPath(path) {
50
+ if (typeof path !== 'string' || path === '') {
51
+ throw new errors.InvalidArgumentsError('Invalid secret path: expected a non-empty string');
52
+ }
53
+ if (UNSAFE_KV_PATH_CHARS.test(path) || path.endsWith(' ') || LONE_SURROGATE.test(path)) {
54
+ throw new errors.InvalidArgumentsError(
55
+ 'Invalid secret path: \'?\', \'#\', \'\\\', control characters, unpaired surrogates and a ' +
56
+ 'trailing space are not allowed'
57
+ );
58
+ }
59
+ if (/%(2e|2f|5c|25)/i.test(path)) {
60
+ throw new errors.InvalidArgumentsError(
61
+ 'Invalid secret path: encoded dots, separators or percent signs are not allowed'
62
+ );
63
+ }
64
+ const trimmed = path.replace(/^\/+/, '');
65
+ if (trimmed.split('/')[0].includes('%')) {
66
+ throw new errors.InvalidArgumentsError('Invalid secret path: the mount must not be percent-encoded');
67
+ }
68
+ // One trailing slash is allowed: list() addresses a folder with it.
69
+ const segments = trimmed.replace(/\/$/, '').split('/');
70
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
71
+ throw new errors.InvalidArgumentsError(
72
+ 'Invalid secret path: empty, \'.\' and \'..\' segments are not allowed'
73
+ );
74
+ }
75
+ return trimmed;
76
+ }
77
+
14
78
  class VaultClient {
15
79
 
16
80
  /**
@@ -20,10 +84,20 @@ class VaultClient {
20
84
  * @param {Object} options.api
21
85
  * @param {String} options.api.url - the url of the vault server
22
86
  * @param {String} [options.api.apiVersion='v1']
87
+ * @param {Object} [options.api.kv] - KV engine options
88
+ * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect the KV version per mount
89
+ * via a `sys/internal/ui/mounts/<path>` round-trip. When false (default) no detection call is
90
+ * ever made and read/list/write behave byte-for-byte as before unless an engine is
91
+ * listed in `options.api.engines`.
92
+ * @param {Object} [options.api.engines] - Static mount-to-version overrides, e.g. `{ secret: 2, legacy: 1 }`.
93
+ * Listed mounts resolve without any detection round-trip (works even with autoDetect off), making
94
+ * this a reliable fallback for Vaults that deny the detection endpoint.
23
95
  * @param {Object} options.auth
24
96
  * @param {String} options.auth.type
25
97
  * @param {Object} options.auth.config - auth configuration variables
26
- * @param {Object|false} options.logger - Logger that supports "error", "info", "warn", "trace", "debug" methods. Uses `console` by default. Pass `false` to disable logging.
98
+ * @param {Object|false} options.logger - Logger that implements all five of "error", "warn", "info", "debug"
99
+ * and "trace"; one missing any of them is silently replaced by the default logger. The default logger
100
+ * writes to `console`, except "debug", which is discarded. Pass `false` to disable logging.
27
101
  */
28
102
  constructor(options) {
29
103
  this.__log = this.__setupLogger(options.logger);
@@ -43,6 +117,26 @@ class VaultClient {
43
117
  );
44
118
 
45
119
  this.__namespace = options.auth.config.namespace;
120
+
121
+ // KV v2 support (opt-in, non-breaking).
122
+ const kvOpts = (options.api && options.api.kv) || {};
123
+ const autoDetect = kvOpts.autoDetect === true;
124
+ const engines = (options.api && options.api.engines) || {};
125
+
126
+ // Detection (the sys/internal/ui/mounts round-trip) only happens when
127
+ // autoDetect is on. With engines set but autoDetect off, listed mounts use
128
+ // the override and any other mount is passthrough (v1) — no detection call
129
+ // is ever made. This keeps the default byte-for-byte and makes engines a
130
+ // working fallback for Vaults that deny the detection endpoint.
131
+ const resolverDisabled = !autoDetect;
132
+
133
+ // Build detectFn lazily — it uses __auth and __api which are already set above.
134
+ const detectFn = (path) => this.__detectMount(path);
135
+
136
+ this.__resolver = new MountResolver(detectFn, engines, this.__log, {
137
+ disabled: resolverDisabled,
138
+ namespace: this.__namespace,
139
+ });
46
140
  }
47
141
 
48
142
  /**
@@ -173,6 +267,96 @@ class VaultClient {
173
267
  return {'X-Vault-Token': token.getId()}
174
268
  }
175
269
 
270
+ // -------------------------------------------------------------------------
271
+ // Detection helper (used by MountResolver's detectFn)
272
+ // -------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Calls GET sys/internal/ui/mounts/<path> with the current auth token.
276
+ * Returns the parsed Vault response body.
277
+ * @private
278
+ */
279
+ __detectMount(path) {
280
+ return this.__auth.getAuthToken()
281
+ .then((token) => {
282
+ const detectPath = `sys/internal/ui/mounts/${path}`;
283
+ return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
284
+ });
285
+ }
286
+
287
+ // -------------------------------------------------------------------------
288
+ // Internal resolve + request helper
289
+ // -------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Resolve the mount version for a path, rewrite the path, make the request,
293
+ * and return a { apiPath, body, version, mount } object.
294
+ *
295
+ * When the resolver is disabled (autoDetect:false, no matching engine), it
296
+ * preserves the caller's literal path byte-for-byte, keeping behaviour
297
+ * identical to the pre-KV-v2 client.
298
+ *
299
+ * @private
300
+ */
301
+ __resolveAndRequest(op, method, path, data) {
302
+ // KV paths are validated before the token fetch, mount resolution and the autoDetect
303
+ // detection request (GHSA-rr65-m54w-rfr9).
304
+ try {
305
+ path = assertSafeKvPath(path);
306
+ } catch (err) {
307
+ return Promise.reject(err);
308
+ }
309
+
310
+ return this.__auth.getAuthToken()
311
+ .then((token) => {
312
+ const headers = this.getHeaders(token);
313
+
314
+ return this.__resolver.resolve(path)
315
+ .then((resolved) => {
316
+ const mount = resolved.mount;
317
+ const version = resolved.version;
318
+ let apiPath;
319
+ let requestData = data;
320
+
321
+ if (version === 2) {
322
+ // Split the path into mount + logical sub-path and rewrite for KV v2
323
+ const logicalPath = this.__logicalPath(path, mount);
324
+ apiPath = rewritePath(version, op, mount, logicalPath);
325
+ // Wrap data in { data: ... } on v2 writes
326
+ if (op === 'write' && data !== null && data !== undefined) {
327
+ requestData = { data };
328
+ }
329
+ } else {
330
+ // v1 / disabled: preserve the caller's literal path byte-for-byte,
331
+ // including any trailing slash, to maintain the old wire behaviour.
332
+ apiPath = path;
333
+ }
334
+
335
+ return this.__api.makeRequest(method, apiPath, requestData, headers)
336
+ .then((body) => ({ body, version, mount, apiPath }));
337
+ });
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Extract the logical path after the mount prefix.
343
+ * e.g. path='secret/foo/bar', mount='secret' => 'foo/bar'
344
+ * @private
345
+ */
346
+ __logicalPath(path, mount) {
347
+ const prefix = trimTrailingSlashes(mount);
348
+ if (path === prefix) return '';
349
+ if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
350
+ // Detection decodes the path, so with a multi-segment mount 'team/k%76/foo' resolves to
351
+ // 'team/kv'; rewriting it as a key inside that mount would address 'team/kv/team/kv/foo'
352
+ // (GHSA-rr65-m54w-rfr9). MountResolver.resolve() already rejects that; this guards the
353
+ // rewrite itself.
354
+ throw new errors.InvalidArgumentsError(
355
+ `Invalid secret path: it does not start with the mount "${prefix}" it resolved to; ` +
356
+ 'do not percent-encode the mount'
357
+ );
358
+ }
359
+
176
360
  /**
177
361
  * Read secret from Vault
178
362
  * @param {string} path - path to the secret
@@ -180,14 +364,14 @@ class VaultClient {
180
364
  */
181
365
  read(path) {
182
366
  this.__log.debug('read secret %s', path);
183
- return this.__auth.getAuthToken()
184
- .then(token => this.__api.makeRequest('GET', path, null, this.getHeaders(token)))
185
- .then(res => {
367
+ return this.__resolveAndRequest('read', 'GET', path, null)
368
+ .then(({ body, version }) => {
186
369
  this.__log.debug('receive secret %s', path);
187
- return Lease.fromResponse(res);
370
+ const normalised = normalizeResponse(version, 'read', body);
371
+ return Lease.fromResponse(normalised);
188
372
  })
189
373
  .catch((reason) => {
190
- this.__log.error('read secret failed: %s', reason.message);
374
+ this.__log.error('read secret failed: %s', safeMessage(reason));
191
375
  throw reason;
192
376
  });
193
377
  }
@@ -200,14 +384,14 @@ class VaultClient {
200
384
  */
201
385
  list(path) {
202
386
  this.__log.debug('list secrets %s', path);
203
- return this.__auth.getAuthToken()
204
- .then(token => this.__api.makeRequest('LIST', path, null, this.getHeaders(token)))
205
- .then(res => {
387
+ return this.__resolveAndRequest('list', 'LIST', path, null)
388
+ .then(({ body, version }) => {
206
389
  this.__log.debug('got secrets list %s', path);
207
- return Lease.fromResponse(res);
390
+ const normalised = normalizeResponse(version, 'list', body);
391
+ return Lease.fromResponse(normalised);
208
392
  })
209
393
  .catch((reason) => {
210
- this.__log.error('list secrets failed: %s', reason.message);
394
+ this.__log.error('list secrets failed: %s', safeMessage(reason));
211
395
  throw reason;
212
396
  });
213
397
  }
@@ -221,14 +405,13 @@ class VaultClient {
221
405
  */
222
406
  write(path, data) {
223
407
  this.__log.debug('write secret %s', path);
224
- return this.__auth.getAuthToken()
225
- .then((token) => this.__api.makeRequest('POST', path, data, this.getHeaders(token)))
226
- .then((response) => {
408
+ return this.__resolveAndRequest('write', 'POST', path, data)
409
+ .then(({ body }) => {
227
410
  this.__log.debug('secret %s was written', path);
228
- return response;
411
+ return body;
229
412
  })
230
413
  .catch((reason) => {
231
- this.__log.error('write secret failed: %s', reason.message);
414
+ this.__log.error('write secret failed: %s', safeMessage(reason));
232
415
  throw reason;
233
416
  });
234
417
  }
@@ -20,9 +20,11 @@ class VaultAppRoleAuth extends VaultBaseAuth {
20
20
  }
21
21
 
22
22
  _authenticate() {
23
+ // Never log the role_id: for a role with bind_secret_id=false it is the whole login
24
+ // credential (GHSA-x5xf-r8x2-647h).
23
25
  this._log.info(
24
- 'making authentication request: role_id=%s',
25
- this.__roleId
26
+ 'making authentication request: approle mount=%s',
27
+ this._mount
26
28
  );
27
29
 
28
30
  const headers = {};
@@ -35,8 +37,8 @@ class VaultAppRoleAuth extends VaultBaseAuth {
35
37
  secret_id: this.__secretId,
36
38
  }, headers).then(res => {
37
39
  this._log.debug(
38
- 'receive token: %s',
39
- res.auth.client_token
40
+ 'received token (accessor=%s)',
41
+ res.auth.accessor
40
42
  );
41
43
  return this._getTokenEntity(res.auth.client_token);
42
44
  });
@@ -4,6 +4,7 @@ const lt = require('long-timeout');
4
4
  const prettyMs = require('pretty-ms');
5
5
  const AuthToken = require('./AuthToken');
6
6
  const errors = require('../errors');
7
+ const safeMessage = require('../safeMessage');
7
8
 
8
9
  class VaultBaseAuth {
9
10
 
@@ -44,8 +45,8 @@ class VaultBaseAuth {
44
45
 
45
46
  if (this.__authToken.isRenewable()) {
46
47
  this._log.debug(
47
- 'setting refresh timer for token %s',
48
- authToken.getId()
48
+ 'setting refresh timer for token (accessor=%s)',
49
+ authToken.getAccessor()
49
50
  );
50
51
  this.__setupTokenRefreshTimer(this.__authToken);
51
52
  }
@@ -106,8 +107,10 @@ class VaultBaseAuth {
106
107
  }).catch(err => {
107
108
  this.__setupTokenRefreshTimer(authToken);
108
109
 
109
- this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${err.message}`);
110
- this._log.error(err);
110
+ // Only the safe message: the error object itself used to be logged too, and a
111
+ // request-promise error carries the request options (the X-Vault-Token header)
112
+ // and a non-2xx response body (GHSA-2vr8-g4vw-7xpm).
113
+ this._log.error(`Cannot refresh auth token with "${authToken.getAccessor()}" accessor. Error: ${safeMessage(err)}`);
111
114
  });
112
115
  }, timer);
113
116
 
@@ -131,7 +134,7 @@ class VaultBaseAuth {
131
134
  return this._getTokenEntity(authToken.getId());
132
135
  })
133
136
  .catch((reason) => {
134
- this._log.error('token renew failed: %s', reason.message);
137
+ this._log.error('token renew failed: %s', safeMessage(reason));
135
138
  throw reason;
136
139
  });
137
140
  }
@@ -99,8 +99,8 @@ class VaultIAMAuth extends VaultBaseAuth {
99
99
  })
100
100
  .then((response) => {
101
101
  this._log.debug(
102
- 'receive token: %s',
103
- response.auth.client_token
102
+ 'received token (accessor=%s)',
103
+ response.auth.accessor
104
104
  );
105
105
  return this._getTokenEntity(response.auth.client_token)
106
106
  })
@@ -27,18 +27,15 @@ class VaultKubernetesAuth extends VaultBaseAuth {
27
27
 
28
28
  const k8sJwtToken = fs.readFileSync(this.__tokenPath).toString();
29
29
  this._log.debug(
30
- 'receive K8s token: %s',
31
- k8sJwtToken
30
+ 'read K8s service-account JWT from "%s" (%d bytes)',
31
+ this.__tokenPath, k8sJwtToken.length
32
32
  );
33
33
 
34
34
  return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
35
35
  role: this.__role,
36
36
  jwt: k8sJwtToken,
37
37
  }).then((res) => {
38
- this._log.debug(
39
- 'receive token: %s',
40
- res.auth.client_token
41
- );
38
+ this._log.debug('received Vault client token from Kubernetes login');
42
39
 
43
40
  return this._getTokenEntity(res.auth.client_token);
44
41
  });
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const { trimTrailingSlashes } = require('./slashes');
4
+
5
+ /**
6
+ * Pure KV path-rewriting and response-normalizing helpers.
7
+ * No I/O; fully testable in isolation.
8
+ *
9
+ * rewritePath(version, op, mount, logicalPath) -> apiPath
10
+ * normalizeResponse(version, op, body) -> normalized body
11
+ */
12
+
13
+ // Map of op -> v2 path segment inserted between mount and logical path.
14
+ const V2_SEGMENT = {
15
+ read: 'data',
16
+ write: 'data',
17
+ delete: 'data',
18
+ update: 'data',
19
+ list: 'metadata',
20
+ readMetadata: 'metadata',
21
+ deleteMetadata: 'metadata',
22
+ deleteVersions: 'delete',
23
+ undeleteVersions:'undelete',
24
+ destroyVersions: 'destroy',
25
+ };
26
+
27
+ /**
28
+ * Rewrite a logical path to the correct API path for the given engine version.
29
+ *
30
+ * @param {number} version - Engine version (2 = KV v2; anything else = passthrough)
31
+ * @param {string} op - Operation name (read, write, list, delete, update, …)
32
+ * @param {string} mount - Mount point (e.g. "secret" or "secret/")
33
+ * @param {string} logicalPath - Path relative to the mount (e.g. "team/svc")
34
+ * @returns {string}
35
+ */
36
+ function rewritePath(version, op, mount, logicalPath) {
37
+ // Normalise trailing slash on mount
38
+ const m = trimTrailingSlashes(mount);
39
+
40
+ if (version !== 2) {
41
+ // v1 / non-kv: simple concatenation
42
+ return logicalPath ? `${m}/${logicalPath}` : m;
43
+ }
44
+
45
+ const segment = V2_SEGMENT[op] || 'data';
46
+ return `${m}/${segment}/${logicalPath}`;
47
+ }
48
+
49
+ /**
50
+ * Normalise a Vault API response body for the given engine version and operation.
51
+ *
52
+ * For v1 / non-kv the body is always returned as-is.
53
+ * For v2:
54
+ * - read: body.data is replaced with body.data.data; body.metadata is set to body.data.metadata
55
+ * - readMetadata: body.data is replaced with the original body.data (unwrapped one level)
56
+ * - write/delete/update/…: body returned as-is
57
+ *
58
+ * @param {number} version
59
+ * @param {string} op
60
+ * @param {*} body
61
+ * @returns {*}
62
+ */
63
+ function normalizeResponse(version, op, body) {
64
+ if (version !== 2) {
65
+ return body;
66
+ }
67
+
68
+ if (op === 'read') {
69
+ if (!body || !body.data) {
70
+ return body;
71
+ }
72
+ // Promote inner data/metadata to top-level response fields
73
+ const result = Object.assign({}, body);
74
+ result.metadata = body.data.metadata;
75
+ result.data = body.data.data;
76
+ return result;
77
+ }
78
+
79
+ if (op === 'readMetadata') {
80
+ if (!body || !body.data) {
81
+ return body;
82
+ }
83
+ const result = Object.assign({}, body);
84
+ result.data = body.data;
85
+ return result;
86
+ }
87
+
88
+ // All other v2 ops (write, list, delete, update, deleteVersions, …): passthrough
89
+ return body;
90
+ }
91
+
92
+ module.exports = { rewritePath, normalizeResponse };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const { StatusCodeError } = require('request-promise/errors');
4
+
5
+ /**
6
+ * A message for `err` that is safe to log or to embed in another error's message
7
+ * (GHSA-2vr8-g4vw-7xpm).
8
+ *
9
+ * request-promise's StatusCodeError message is `"<status> - <JSON body>"`, and a non-2xx Vault
10
+ * body can still carry secret material: a transit batch decrypt with one bad item answers 400
11
+ * with the other items' plaintexts in `data.batch_results`. So for those only the status and
12
+ * Vault's own `errors` strings are kept. Any other error's message is returned unchanged, and a
13
+ * rejection value that is not an error (`undefined`, a string) is stringified, so the catch
14
+ * handler logging it cannot throw.
15
+ *
16
+ * @param {*} err
17
+ * @returns {string}
18
+ */
19
+ function safeMessage(err) {
20
+ if (!(err instanceof StatusCodeError)) {
21
+ return err && err.message !== undefined ? err.message : String(err);
22
+ }
23
+
24
+ const body = err.error;
25
+ if (!body || typeof body !== 'object' || !Array.isArray(body.errors)) {
26
+ return `${err.statusCode} - (response body omitted)`;
27
+ }
28
+
29
+ const vaultErrors = body.errors.filter((e) => typeof e === 'string');
30
+ return `${err.statusCode} - ${JSON.stringify({ errors: vaultErrors })}`;
31
+ }
32
+
33
+ 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 };