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 +151 -1
- package/package.json +1 -1
- package/src/Lease.js +16 -3
- package/src/MountResolver.js +240 -0
- package/src/VaultApiClient.js +27 -3
- package/src/VaultClient.js +199 -16
- 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 +92 -0
- package/src/safeMessage.js +33 -0
- package/src/slashes.js +32 -0
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>
|
|
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.
|
|
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;
|
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
|
@@ -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
|
|
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.
|
|
184
|
-
.then(
|
|
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
|
-
|
|
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
|
|
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.
|
|
204
|
-
.then(
|
|
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
|
-
|
|
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
|
|
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.
|
|
225
|
-
.then((
|
|
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
|
|
411
|
+
return body;
|
|
229
412
|
})
|
|
230
413
|
.catch((reason) => {
|
|
231
|
-
this.__log.error('write secret failed: %s', reason
|
|
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:
|
|
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
|
});
|
|
@@ -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 };
|