node-vault-client 1.0.3 → 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
@@ -136,6 +136,45 @@ await client.read('secret-v2/app/config'); // detected as v2
136
136
  `api.engines`, and `autoDetect` recognises them as non-KV automatically.
137
137
  - If detection fails (e.g. permission denied) and no `api.engines` override matches, the read
138
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.
139
178
 
140
179
  ## Supported Auth Backends
141
180
 
@@ -179,7 +218,7 @@ Client constructor function.
179
218
  | options.auth | <code>Object</code> | | |
180
219
  | options.auth.type | <code>String</code> | | |
181
220
  | options.auth.config | <code>Object</code> | | auth configuration variables |
182
- | 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). |
183
222
 
184
223
  #### vaultClient.fillNodeConfig()
185
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.3",
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"
@@ -1,6 +1,34 @@
1
1
  'use strict';
2
2
 
3
- const { VaultError } = require('./errors');
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
+ }
4
32
 
5
33
  /**
6
34
  * Resolves the KV engine version for a given secret path.
@@ -16,13 +44,21 @@ const { VaultError } = require('./errors');
16
44
  * @param {Object} logger - logger with .debug(), .error() etc.
17
45
  * @param {Object} [opts] - additional options
18
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)
19
48
  */
20
49
  class MountResolver {
21
50
  constructor(detectFn, enginesOverride, logger, opts) {
22
51
  this.__detectFn = detectFn;
23
- this.__engines = enginesOverride || {};
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)]);
24
56
  this.__log = logger;
25
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}/` : '';
26
62
 
27
63
  // Cache: canonical mount path (no trailing slash) -> { mount, version, type }
28
64
  this.__cache = new Map();
@@ -40,6 +76,10 @@ class MountResolver {
40
76
  // 1. Check engines override (longest-prefix match). This applies even when
41
77
  // auto-detection is disabled, so listed mounts resolve with no I/O.
42
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
+ }
43
83
  if (override !== null) {
44
84
  this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
45
85
  return Promise.resolve(override);
@@ -60,8 +100,15 @@ class MountResolver {
60
100
  // if it does not (two distinct multi-segment mounts share a first segment,
61
101
  // e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
62
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.
63
105
  const interimKey = path.split('/')[0];
64
- return this.__detectWithCache(interimKey, path);
106
+ return this.__detectWithCache(interimKey, path).then((entry) => {
107
+ if (!containsPath(entry.mount, path)) {
108
+ throw encodedMountError(entry.mount);
109
+ }
110
+ return entry;
111
+ });
65
112
  }
66
113
 
67
114
  // -------------------------------------------------------------------------
@@ -73,16 +120,9 @@ class MountResolver {
73
120
  * Returns { mount, version } or null if no match found.
74
121
  */
75
122
  __enginesOverrideLookup(path) {
76
- const entries = Object.entries(this.__engines);
77
- if (entries.length === 0) return null;
78
-
79
- // Sort by descending key length (longest first) for prefix match
80
- entries.sort((a, b) => b[0].length - a[0].length);
81
-
82
- for (const [prefix, version] of entries) {
83
- const normalised = prefix.replace(/\/+$/, '');
84
- if (path === normalised || path.startsWith(normalised + '/')) {
85
- return { mount: normalised, version: Number(version), type: 'kv' };
123
+ for (const [prefix, version] of this.__engines) {
124
+ if (containsPath(prefix, path)) {
125
+ return { mount: prefix, version, type: 'kv' };
86
126
  }
87
127
  }
88
128
  return null;
@@ -101,7 +141,7 @@ class MountResolver {
101
141
  __detectWithCache(interimKey, path) {
102
142
  // Check persistent cache first (keyed on canonical mount path)
103
143
  for (const [mountKey, entry] of this.__cache) {
104
- if (path === mountKey || path.startsWith(mountKey + '/')) {
144
+ if (containsPath(mountKey, path)) {
105
145
  return Promise.resolve(entry);
106
146
  }
107
147
  }
@@ -111,7 +151,7 @@ class MountResolver {
111
151
  // Join the existing in-flight promise but verify the result matches our path.
112
152
  // If the detection was for a different sub-mount, start a fresh detection.
113
153
  return this.__inflight.get(interimKey).then((entry) => {
114
- if (path === entry.mount || path.startsWith(entry.mount + '/')) {
154
+ if (containsPath(entry.mount, path)) {
115
155
  return entry;
116
156
  }
117
157
  // The shared detection resolved to a different mount — start our own.
@@ -128,8 +168,16 @@ class MountResolver {
128
168
  throw new Error('Unexpected empty detection response');
129
169
  }
130
170
 
131
- const canonicalMount = (info.path || interimKey).replace(/\/+$/, '');
132
- const type = info.type || 'unknown';
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;
133
181
  const optVersion = info.options && info.options.version;
134
182
  // Only kv v2 gets special treatment; everything else is passthrough
135
183
  const version = (type === 'kv' && Number(optVersion) === 2) ? 2 : 1;
@@ -137,8 +185,11 @@ class MountResolver {
137
185
  const entry = { mount: canonicalMount, version, type };
138
186
  this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
139
187
 
140
- // Store in persistent cache
141
- this.__cache.set(canonicalMount, entry);
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
+ }
142
193
  // Clear in-flight
143
194
  this.__inflight.delete(interimKey);
144
195
  return entry;
@@ -146,8 +197,10 @@ class MountResolver {
146
197
  .catch((err) => {
147
198
  // Clear in-flight so callers can retry
148
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).
149
202
  throw new VaultError(
150
- `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err.message}. ` +
203
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${safeMessage(err)}. ` +
151
204
  'Set api.engines or disable autoDetect to bypass detection.'
152
205
  );
153
206
  });
@@ -156,6 +209,26 @@ class MountResolver {
156
209
  return promise;
157
210
  }
158
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
+
159
232
  /**
160
233
  * Simple first-segment extraction used only for the disabled path.
161
234
  */
@@ -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
  }
@@ -11,8 +11,70 @@ const VaultNodeConfig = require('./VaultNodeConfig');
11
11
  const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
12
12
  const MountResolver = require('./MountResolver');
13
13
  const { rewritePath, normalizeResponse } = require('./kvTransform');
14
+ const safeMessage = require('./safeMessage');
15
+ const { trimTrailingSlashes } = require('./slashes');
14
16
  const vaultInstances = {};
15
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
+
16
78
  class VaultClient {
17
79
 
18
80
  /**
@@ -33,7 +95,9 @@ class VaultClient {
33
95
  * @param {Object} options.auth
34
96
  * @param {String} options.auth.type
35
97
  * @param {Object} options.auth.config - auth configuration variables
36
- * @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.
37
101
  */
38
102
  constructor(options) {
39
103
  this.__log = this.__setupLogger(options.logger);
@@ -71,6 +135,7 @@ class VaultClient {
71
135
 
72
136
  this.__resolver = new MountResolver(detectFn, engines, this.__log, {
73
137
  disabled: resolverDisabled,
138
+ namespace: this.__namespace,
74
139
  });
75
140
  }
76
141
 
@@ -234,6 +299,14 @@ class VaultClient {
234
299
  * @private
235
300
  */
236
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
+
237
310
  return this.__auth.getAuthToken()
238
311
  .then((token) => {
239
312
  const headers = this.getHeaders(token);
@@ -271,10 +344,17 @@ class VaultClient {
271
344
  * @private
272
345
  */
273
346
  __logicalPath(path, mount) {
274
- const prefix = mount.replace(/\/+$/, '');
347
+ const prefix = trimTrailingSlashes(mount);
275
348
  if (path === prefix) return '';
276
349
  if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
277
- return path;
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
+ );
278
358
  }
279
359
 
280
360
  /**
@@ -291,7 +371,7 @@ class VaultClient {
291
371
  return Lease.fromResponse(normalised);
292
372
  })
293
373
  .catch((reason) => {
294
- this.__log.error('read secret failed: %s', reason.message);
374
+ this.__log.error('read secret failed: %s', safeMessage(reason));
295
375
  throw reason;
296
376
  });
297
377
  }
@@ -311,7 +391,7 @@ class VaultClient {
311
391
  return Lease.fromResponse(normalised);
312
392
  })
313
393
  .catch((reason) => {
314
- this.__log.error('list secrets failed: %s', reason.message);
394
+ this.__log.error('list secrets failed: %s', safeMessage(reason));
315
395
  throw reason;
316
396
  });
317
397
  }
@@ -331,7 +411,7 @@ class VaultClient {
331
411
  return body;
332
412
  })
333
413
  .catch((reason) => {
334
- this.__log.error('write secret failed: %s', reason.message);
414
+ this.__log.error('write secret failed: %s', safeMessage(reason));
335
415
  throw reason;
336
416
  });
337
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
  });
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { trimTrailingSlashes } = require('./slashes');
4
+
3
5
  /**
4
6
  * Pure KV path-rewriting and response-normalizing helpers.
5
7
  * No I/O; fully testable in isolation.
@@ -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,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 };