Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,17 @@ jobs:
- name: Install dependencies
run: npm ci

# Release-time guard (issue #111): publishing x.y.z requires a matching "# x.y.z ..."
# release-notes heading in CHANGELOG.md, so the changelog cannot silently fall out of
# date. Keeping the check here (instead of on every PR) avoids blocking non-user-facing
# PRs while still enforcing the CONTRIBUTING.md convention where it matters.
- name: Check CHANGELOG.md covers this release
run: |
VERSION="$(node -p "require('./package.json').version")"
if ! grep -Eq "^# ${VERSION//./\\.}( |$)" CHANGELOG.md; then
echo "::error file=CHANGELOG.md::No release-notes heading for version ${VERSION}. Move the '# Unreleased' notes into a '# ${VERSION} Release notes (YYYY-MM-DD)' section before publishing."
exit 1
fi

- name: Publish
run: npm publish --provenance --access public
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
point (one `resolver.resolve()` call site instead of three). No behavior change — the pipeline
is locked by new characterization tests across v1/v2 mounts, namespaces, and extra headers.
Closes #110.
- Non-2xx Vault responses are now rejected with a typed `VaultHttpError` (part of the `VaultError`
hierarchy, exported from the errors module), so callers can `instanceof`-check HTTP failures.
Backward compatible: the message (`"<status> - <body text>"`) and the `statusCode`/`error`
properties are unchanged, and the thrown value is still an `instanceof Error`.
- Auth backends now fail fast with `InvalidArgumentsError` when a required config field is missing:
`role_id` for AppRole, `role` for IAM and Kubernetes (previously only Token auth validated its
input, and a missing/empty config surfaced later as an unhelpful login failure or `TypeError`).
Valid configurations behave exactly as before.

# 2.0.4 Release notes (2026-07-02)

Expand Down
7 changes: 3 additions & 4 deletions src/VaultApiClient.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const errors = require('./errors');

/**
* Joins URL segments with single slashes while preserving the leading
* protocol (e.g. "https://"). Replaces the former `url-join` dependency.
Expand Down Expand Up @@ -111,10 +113,7 @@ class VaultApiClient {
}

if (!response.ok) {
const error = new Error(`${response.status} - ${text}`);
error.statusCode = response.status;
error.error = body;
throw error;
throw new errors.VaultHttpError(response.status, text, body);
}

// Do not log the response body: Vault responses carry secret
Expand Down
3 changes: 3 additions & 0 deletions src/VaultNodeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,6 @@ class VaultNodeConfig {
}

module.exports = VaultNodeConfig;
// The prototype-pollution guard in deepMerge is a deliberate security control;
// the helper is exported so tests can exercise it directly.
module.exports.deepMerge = deepMerge;
5 changes: 5 additions & 0 deletions src/auth/VaultAppRoleAuth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const VaultBaseAuth = require('./VaultBaseAuth');
const errors = require('../errors');

class VaultAppRoleAuth extends VaultBaseAuth {

Expand All @@ -17,6 +18,10 @@ class VaultAppRoleAuth extends VaultBaseAuth {
constructor(apiClient, logger, config, mount) {
super(apiClient, logger, mount || 'approle');

if (!config || !config.role_id) {
throw new errors.InvalidArgumentsError('"role_id" should be provided for VaultAppRoleAuth');
}

this.__roleId = config.role_id;
this.__secretId = config.secret_id;
}
Expand Down
4 changes: 4 additions & 0 deletions src/auth/VaultBaseAuth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use strict';

// `long-timeout` is unmaintained (last release 2016) but tiny (~50 LOC), dependency-free and
// stable: it only works around setTimeout's 32-bit signed-int limit (~24.8 days), which real
// Vault token TTLs can exceed. Audited and deliberately kept as-is (see issue #111); revisit
// only if a maintained alternative or a Node.js core fix appears.
const lt = require('long-timeout');
const AuthToken = require('./AuthToken');
const errors = require('../errors');
Expand Down
4 changes: 4 additions & 0 deletions src/auth/VaultIAMAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class VaultIAMAuth extends VaultBaseAuth {
constructor(api, logger, config, mount) {
super(api, logger, mount || 'aws');

if (!config || !config.role) {
throw new errors.InvalidArgumentsError('"role" should be provided for VaultIAMAuth');
}

this.__role = config.role;
this.__iam_server_id_header_value = config.iam_server_id_header_value;
this.__region = config.region;
Expand Down
5 changes: 5 additions & 0 deletions src/auth/VaultKubernetesAuth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require('fs');
const VaultBaseAuth = require('./VaultBaseAuth');
const errors = require('../errors');

class VaultKubernetesAuth extends VaultBaseAuth {
/**
Expand All @@ -15,6 +16,10 @@ class VaultKubernetesAuth extends VaultBaseAuth {
constructor(apiClient, logger, config, mount) {
super(apiClient, logger, mount || 'kubernetes');

if (!config || !config.role) {
throw new errors.InvalidArgumentsError('"role" should be provided for VaultKubernetesAuth');
}

this.__role = config.role;
this.__tokenPath = '/var/run/secrets/kubernetes.io/serviceaccount/token';
if (config.tokenPath !== undefined) {
Expand Down
2 changes: 1 addition & 1 deletion src/auth/VaultTokenAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class VaultTokenAuth extends VaultBaseAuth {
constructor(apiClient, logger, config, mount) {
super(apiClient, logger, mount || 'token');

if (!config.token) {
if (!config || !config.token) {
throw new errors.InvalidArgumentsError('Auth token should be provided for VaultTokenAuth');
}

Expand Down
23 changes: 23 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,33 @@ class InvalidAWSCredentialsError extends InvalidArgumentsError {}
class AuthTokenExpiredError extends VaultError {}
class UnsupportedOperationError extends VaultError {}

/**
* A non-2xx HTTP response from the Vault server.
*
* Preserves the legacy plain-`Error` shape previously thrown by
* {@link VaultApiClient#makeRequest} — the message stays `"<status> - <raw body text>"` and the
* `statusCode`/`error` properties keep their meaning — while letting callers
* `instanceof`-check HTTP failures against the `VaultError` hierarchy.
*/
class VaultHttpError extends VaultError {
/**
* @param {number} statusCode - HTTP status code of the response.
* @param {string} text - raw response body text, used in the message.
* @param {*} [body] - parsed JSON body when the response was valid JSON, the raw text
* otherwise, or `undefined` for an empty body. Exposed as `error.error`.
*/
constructor(statusCode, text, body) {
super(`${statusCode} - ${text}`);
this.statusCode = statusCode;
this.error = body;
}
}

module.exports = {
VaultError,
InvalidArgumentsError,
InvalidAWSCredentialsError,
AuthTokenExpiredError,
UnsupportedOperationError,
VaultHttpError,
};
19 changes: 19 additions & 0 deletions test/Lease.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ describe('Lease', function () {
});
});

describe('#getMetadata()', function () {
const metadata = { created_time: '2026-07-02T00:00:00Z', version: 3 };

it('returns the KV v2 version metadata when present', function () {
const lease = new Lease('r', 'l', 10, false, { a: 1 }, metadata);
expect(lease.getMetadata()).to.equal(metadata);
});

it('is mapped from response.metadata by fromResponse', function () {
const lease = Lease.fromResponse({ ...response, metadata });
expect(lease.getMetadata()).to.deep.equal(metadata);
});

it('returns undefined for KV v1 / non-KV leases', function () {
expect(Lease.fromResponse(response).getMetadata()).to.equal(undefined);
expect(new Lease('r', 'l', 10, false, {}).getMetadata()).to.equal(undefined);
});
});

describe('#isRenewable()', function () {
it('reflects the renewable flag', function () {
expect(new Lease('r', 'l', 10, true, {}).isRenewable()).to.equal(true);
Expand Down
56 changes: 56 additions & 0 deletions test/VaultApiClient.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import sinon from 'sinon';
import { expect, use } from 'chai';
import sinonChai from 'sinon-chai';
import VaultApiClient from '../src/VaultApiClient.js';
import errors from '../src/errors.js';

use(sinonChai);

Expand Down Expand Up @@ -125,6 +126,61 @@ describe('VaultApiClient', function () {
);
});

it('rejects a non-2xx JSON response with a VaultHttpError carrying the parsed body on err.error', function () {
responder = (req, res) => {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end('{"errors":["permission denied"]}');
};
const api = new VaultApiClient({ url: baseUrl }, logger);
return api.makeRequest('GET', '/secret/foo').then(
() => { throw new Error('expected rejection'); },
(err) => {
expect(err).to.be.instanceOf(errors.VaultHttpError);
expect(err).to.be.instanceOf(errors.VaultError);
expect(err).to.be.instanceOf(Error);
// Legacy plain-Error shape is preserved: same message, same properties.
expect(err.message).to.equal('403 - {"errors":["permission denied"]}');
expect(err.statusCode).to.equal(403);
expect(err.error).to.deep.equal({ errors: ['permission denied'] });
}
);
});

it('falls back to the raw text on err.error when the error body is not JSON', function () {
responder = (req, res) => {
res.statusCode = 500;
res.end('boom');
};
const api = new VaultApiClient({ url: baseUrl }, logger);
return api.makeRequest('GET', '/secret/foo').then(
() => { throw new Error('expected rejection'); },
(err) => {
expect(err).to.be.instanceOf(errors.VaultHttpError);
expect(err.message).to.equal('500 - boom');
expect(err.statusCode).to.equal(500);
expect(err.error).to.equal('boom');
}
);
});

it('leaves err.error undefined when the error body is empty', function () {
responder = (req, res) => {
res.statusCode = 500;
res.end();
};
const api = new VaultApiClient({ url: baseUrl }, logger);
return api.makeRequest('GET', '/secret/foo').then(
() => { throw new Error('expected rejection'); },
(err) => {
expect(err).to.be.instanceOf(errors.VaultHttpError);
expect(err.message).to.equal('500 - ');
expect(err.statusCode).to.equal(500);
expect(err.error).to.equal(undefined);
}
);
});

it('merges config.requestOptions.headers, with per-request headers taking precedence', function () {
const api = new VaultApiClient({
url: baseUrl,
Expand Down
50 changes: 50 additions & 0 deletions test/VaultNodeConfig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,56 @@ describe('VaultNodeConfig', function () {
});
});

describe('deepMerge()', function () {
const { deepMerge } = VaultNodeConfig;

it('is exported for direct use in tests', function () {
expect(deepMerge).to.be.a('function');
});

it('skips an own __proto__ key so a malicious source cannot pollute prototypes', function () {
// JSON.parse creates an *own* "__proto__" data property (an object literal would
// set the prototype instead), which is exactly how polluted input arrives.
const source = JSON.parse('{"__proto__": {"polluted": "yes"}, "safe": 1}');
const target = {};
deepMerge(target, source);

expect(target.safe).to.equal(1);
expect(Object.hasOwn(target, '__proto__')).to.equal(false);
expect(Object.getPrototypeOf(target)).to.equal(Object.prototype);
expect({}.polluted, 'Object.prototype must not be polluted').to.equal(undefined);
});

it('skips constructor and prototype keys', function () {
const source = JSON.parse('{"constructor": {"prototype": {"polluted": "yes"}}, "prototype": {"polluted": "yes"}, "safe": 1}');
const target = {};
deepMerge(target, source);

expect(target.safe).to.equal(1);
expect(Object.hasOwn(target, 'constructor')).to.equal(false);
expect(Object.hasOwn(target, 'prototype')).to.equal(false);
expect(target.constructor).to.equal(Object);
expect({}.polluted, 'Object.prototype must not be polluted').to.equal(undefined);
});

it('merges nested objects in place, skips undefined source values and returns the target', function () {
const target = { nested: { keep: 1, replace: 1 }, scalar: 'a' };
const result = deepMerge(target, { nested: { replace: 2, add: 3 }, scalar: undefined });

expect(result).to.equal(target);
expect(target).to.deep.equal({ nested: { keep: 1, replace: 2, add: 3 }, scalar: 'a' });
});

it('deep-clones object values assigned over non-object targets', function () {
const source = { obj: { a: 1 } };
const target = { obj: 'scalar' };
deepMerge(target, source);

expect(target.obj).to.deep.equal({ a: 1 });
expect(target.obj, 'must not share the source reference').to.not.equal(source.obj);
});
});

describe('#populate()', function () {
function instance(substitutionMap, dataByPath) {
process.env.NODE_CONFIG_DIR = CONFIG_BASE;
Expand Down
13 changes: 13 additions & 0 deletions test/auth.appRole.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect, use } from 'chai';
import sinonChai from 'sinon-chai';
import VaultApiClient from '../src/VaultApiClient.js';
import VaultAppRoleAuth from '../src/auth/VaultAppRoleAuth.js';
import errors from '../src/errors.js';

use(sinonChai);

Expand Down Expand Up @@ -113,6 +114,18 @@ describe('AppRole auth backend', function () {
});
});

describe('config validation', function () {
it('throws InvalidArgumentsError when role_id is missing', () => {
expect(() => new VaultAppRoleAuth(getApiStub(), logger, { secret_id: 'secret456' }))
.to.throw(errors.InvalidArgumentsError, '"role_id" should be provided for VaultAppRoleAuth');
});

it('throws InvalidArgumentsError when the config is missing entirely', () => {
expect(() => new VaultAppRoleAuth(getApiStub(), logger, undefined))
.to.throw(errors.InvalidArgumentsError, '"role_id" should be provided for VaultAppRoleAuth');
});
});

describe('does not leak the client_token to the logger (regression #104)', function () {
const CLIENT_TOKEN = 's.RAWAPPROLETOKENSHOULDNEVERBELOGGED';
const ACCESSOR = 'approle-accessor-1234';
Expand Down
11 changes: 11 additions & 0 deletions test/auth.base.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ describe('VaultBaseAuth', function () {
const auth = new TestAuth(api, 'mount', { authStub: sinon.stub().resolves(renewable) });
auth._log = _.assign({}, logger, { error: errorSpy });

let renewalsAfterFirstFailure;

return auth.getAuthToken()
.then(() => {
clock.tick(50000);
Expand All @@ -259,6 +261,15 @@ describe('VaultBaseAuth', function () {
.then(() => {
expect(api.makeRequest).to.have.been.calledWith('POST', '/auth/token/renew-self');
expect(errorSpy).to.have.been.called;
// The timer must actually re-arm after the failure ...
expect(auth.__refreshTimeout).to.not.equal(null);
renewalsAfterFirstFailure = api.makeRequest.callCount;
clock.tick(50000);
return flush();
})
.then(() => {
// ... so advancing the clock again triggers another renewal attempt.
expect(api.makeRequest.callCount).to.be.greaterThan(renewalsAfterFirstFailure);
});
});
});
Expand Down
12 changes: 12 additions & 0 deletions test/auth.iam.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,18 @@ describe('Unit AWS auth backend :: IAM', function () {
});
});

describe('config validation', function () {
it('Should throw InvalidArgumentsError if the role is missing', () => {
expect(() => new VaultIAMAuth(getApiStub(), logger, {}, 'aws'))
.to.throw(errors.InvalidArgumentsError, '"role" should be provided for VaultIAMAuth');
});

it('Should throw InvalidArgumentsError if the config is missing entirely', () => {
expect(() => new VaultIAMAuth(getApiStub(), logger, undefined, 'aws'))
.to.throw(errors.InvalidArgumentsError, '"role" should be provided for VaultIAMAuth');
});
});

describe('base64 encoding', function () {
it('encodes via Buffer.from (no deprecated new Buffer) and round-trips', function () {
const auth = new VaultIAMAuth(getApiStub(), logger, { role: 'MyRole' }, 'aws');
Expand Down
Loading
Loading