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 +40 -1
- package/package.json +1 -1
- package/src/MountResolver.js +93 -20
- package/src/VaultApiClient.js +27 -3
- package/src/VaultClient.js +86 -6
- package/src/auth/VaultAppRoleAuth.js +6 -4
- package/src/auth/VaultBaseAuth.js +8 -5
- package/src/auth/VaultIAMAuth.js +2 -2
- package/src/auth/VaultKubernetesAuth.js +3 -6
- package/src/kvTransform.js +3 -1
- package/src/safeMessage.js +33 -0
- package/src/slashes.js +32 -0
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>
|
|
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
|
+
"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/MountResolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
77
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
//
|
|
141
|
-
|
|
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
|
|
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
|
*/
|
package/src/VaultApiClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/VaultClient.js
CHANGED
|
@@ -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
|
|
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
|
|
347
|
+
const prefix = trimTrailingSlashes(mount);
|
|
275
348
|
if (path === prefix) return '';
|
|
276
349
|
if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
|
|
277
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
25
|
-
this.
|
|
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
|
-
'
|
|
39
|
-
res.auth.
|
|
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
|
|
48
|
-
authToken.
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
137
|
+
this._log.error('token renew failed: %s', safeMessage(reason));
|
|
135
138
|
throw reason;
|
|
136
139
|
});
|
|
137
140
|
}
|
package/src/auth/VaultIAMAuth.js
CHANGED
|
@@ -99,8 +99,8 @@ class VaultIAMAuth extends VaultBaseAuth {
|
|
|
99
99
|
})
|
|
100
100
|
.then((response) => {
|
|
101
101
|
this._log.debug(
|
|
102
|
-
'
|
|
103
|
-
response.auth.
|
|
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
|
-
'
|
|
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
|
});
|
package/src/kvTransform.js
CHANGED
|
@@ -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
|
|
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 };
|