RTL/test/backend/common.test.mjs

210 lines
9.8 KiB
JavaScript
Raw Permalink Normal View History

Release 0.15.10 (#1665) * Update version 0.15.10 * Update project dependencies to resolve Dependabot security alerts Applies the fixes from the open Dependabot PRs (#1648, #1649, #1650) in a single pass on the release branch, regenerating the lockfile from scratch. axios 1.16.0 -> 1.18.1 was the only production exposure (10 advisories). Transitive deps moved to their fixed in-range versions (fast-uri 3.1.4, form-data, qs, tough-cookie, tar, del, globby); dev toolchain took safe bumps (nodemon 3.1.14, eslint 9.39.5, @typescript-eslint 8.65.0). Drops the unused protractor devDependency: no e2e directory, no config and no e2e target in angular.json, but 100 packages and the deprecated request stack behind it. That clears both critical advisories. npm audit: 50 (2 critical) -> 29 (0 critical); production deps 1 -> 0. Remaining findings are dev-only tooling needing an Angular 21 migration rather than a version bump. Verified: lint, 204 frontend specs, backend + frontend production builds, and 19 API checks against the docker regtest fixture covering LND, Core Lightning and Eclair (getinfo, channels, peers, invoices, payments and forwarding history). * Fill in PR number in release note (#1653) * Harden login request validation (#1654) Tightens server-side validation of authentication requests, guards the password-reset route behind an authenticated session, and wires the backend regression suite (test/backend/) into npm run test. Users with two-factor authentication enabled are encouraged to update promptly. Verified: backend specs 12/12, lint green, frontend specs 204/204, and the full authentication matrix end-to-end on the docker regtest fixture. * Reduce exposure of authentication secrets in logs and config responses (#1659) * Reduce exposure of authentication secrets in logs and config responses * Fill in PR number in release note (#1659) * Harden redaction helpers and secret restore paths * Pin deployment auth switches server-side and harden settings persistence * Contain backup file reads and harden config persistence * Pin backup containment root and preserve config file mode on save * Update Angular framework packages to 20.3.27 (#1661) * Update Angular framework packages to 20.3.27 Batches the three Dependabot PRs open against master for the Angular framework (@angular/core #1658, @angular/compiler #1657, @angular/common #1655) into one update on the release branch. The framework packages are pinned to exact versions and their peer ranges require them to move together, so all nine 20.3.26 packages go to 20.3.27: animations, common, compiler, compiler-cli, core, forms, platform-browser, platform-browser-dynamic and router. Patch-level upstream fixes only, no advisories. The update stays inside Angular 20 - @angular/build and @angular/cli (20.3.32) and @angular/cdk/@angular/material (20.2.14) are already at the top of their v20 lines - so it does not pull in the Angular 21 migration tracked by #1650. Rebuilt frontend/ for the new framework code. backend/ is unchanged, as no server/ source moved. * Fill in PR number in release note (#1661) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 (#1651) * Bound remaining unbounded alias-resolution fan-outs in LND graph.ts and channels.ts Fixes #1630 * Address review feedback: fix options race, error handling, release notes * Improve release notes entry to cover full PR scope * Address review feedback: per-task options copy, exclude qs from alias requests * Stop logging the eclair auth header at DEBUG level (#1664) * Stop logging the eclair auth header at DEBUG level getChannels in the eclair channels controller logged its whole request options object. Eclair authenticates with HTTP basic auth, so those options carry the configured lnApiPassword in an authorization header - raising an eclair node's logLevel to DEBUG wrote "authorization":"Basic <base64>" into the node log file, which is a recoverable form of the credential and is routinely shared when debugging. The log now carries only the request url and form, matching every other DEBUG log in the controllers. This was the only site in server/ passing a whole options object to the logger; the rest log options.form, .url, .body or .qs, none of which hold credentials. Present since 0.12.0 and only reachable by opting in to DEBUG (the default log level is ERROR), but it contradicted the logging guarantee stated for #1659. Found by scanning node logs at DEBUG while verifying the 0.15.10 branch against the regtest fixture. Regression test added in test/backend/eclair-channels.test.mjs; it fails on the previous code with "auth header key must not reach the node log". * Fill in PR number in release note (#1664) --------- Co-authored-by: Osuji <weezdomosuji@gmail.com>
2026-08-03 22:49:14 -07:00
import assert from 'node:assert/strict';
import test from 'node:test';
import { Common } from '../../backend/utils/common.js';
test('maskPasswords masks TOTP and SSO cookie secrets along with passwords', () => {
const config = {
secret2FA: 'JBSWY3DPEHPK3PXP',
multiPassHashed: 'password-hash',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', cookieValue: 'live-sso-cookie' },
nodes: [{ index: 1, authentication: { lnApiPassword: 'eclair-pass', macaroonPath: '/macaroon/path' } }]
};
const masked = Common.maskPasswords(config);
assert.equal(masked.secret2FA, '*'.repeat(20));
assert.equal(masked.SSO.cookieValue, '*'.repeat(20));
assert.equal(masked.multiPassHashed, '*'.repeat(20));
assert.equal(masked.nodes[0].authentication.lnApiPassword, '*'.repeat(20));
// Paths are configuration, not secrets — they must stay visible for the settings UI.
assert.equal(masked.nodes[0].authentication.macaroonPath, '/macaroon/path');
assert.equal(masked.SSO.rtlCookiePath, '/cookie-path');
});
test('removeSecureData strips the SSO cookie along with the other secrets', () => {
const config = {
rtlConfFilePath: '/conf',
rtlPass: 'password-hash',
multiPassHashed: 'password-hash',
secret2FA: 'JBSWY3DPEHPK3PXP',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' },
nodes: [{ index: 1, authentication: { macaroonPath: '/macaroon/path', runeValue: 'rune', options: {} } }]
};
const cleaned = Common.removeSecureData(config);
assert.equal(cleaned.rtlConfFilePath, undefined);
assert.equal(cleaned.rtlPass, undefined);
assert.equal(cleaned.multiPassHashed, undefined);
assert.equal(cleaned.secret2FA, undefined);
assert.equal(cleaned.SSO.cookieValue, undefined);
// Non-secret SSO settings survive — the settings UI renders them.
assert.equal(cleaned.SSO.rtlCookiePath, '/cookie-path');
assert.equal(cleaned.nodes[0].authentication.macaroonPath, undefined);
});
test('removeSecureData does not mutate its input', () => {
// cookieValue is runtime-only: if a caller ever passes the live appConfig, an in-place
// delete would wipe SSO state with no way to restore it. The function must clone.
const config = { rtlPass: 'password-hash', SSO: { cookieValue: 'live-sso-cookie' }, nodes: [] };
Common.removeSecureData(config);
assert.equal(config.rtlPass, 'password-hash');
assert.equal(config.SSO.cookieValue, 'live-sso-cookie');
});
test('maskPasswords masks rtlPass and runeValue', () => {
const config = {
rtlPass: 'login-hash',
nodes: [{ index: 1, authentication: { runeValue: 'cln-rune' } }]
};
const masked = Common.maskPasswords(config);
assert.equal(masked.rtlPass, '*'.repeat(20));
assert.equal(masked.nodes[0].authentication.runeValue, '*'.repeat(20));
});
test('maskPasswords tolerates null values and numeric keys without skipping secrets', () => {
// Integer-like keys order first; the recursion must not clobber its own key list, and
// typeof null === 'object' must not send it into Object.keys(null).
const config = { '1': { nested: 'value' }, lnApiPassword: 'eclair-pass', nothing: null };
const masked = Common.maskPasswords(config);
assert.equal(masked.lnApiPassword, '*'.repeat(20));
assert.equal(masked.nothing, null);
assert.deepEqual(masked['1'], { nested: 'value' });
});
test('handleError does not echo the absolute file path to the caller', () => {
// The path belongs in the server log, not in the API response.
const err = Common.handleError({ code: 'ENOENT', path: '/secret/dir/RTL-Config.json' }, 'Test', 'Reading Config Error', { lnImplementation: 'LND', settings: {} });
assert.equal(err.error.includes('/secret/dir'), false);
assert.equal(err.message.includes('/secret/dir'), false);
});
test('handleError keeps the absolute path out of controller-wrapped errors too', () => {
// RTLConf handlers pass { statusCode, message, error: errRes } wrappers; the response
// must resolve to the caller's generic message, never the wrapped fs error's path.
const err = Common.handleError(
{ statusCode: 500, message: 'Reading File Error', error: { code: 'ENOENT', path: '/secret/dir/x.bak' } },
'Test', 'Reading File Error', { lnImplementation: 'LND', settings: {} }
);
assert.equal(err.error.includes('/secret/dir'), false);
assert.equal(err.message.includes('/secret/dir'), false);
});
test('maskPasswords masks every value under a headers key', () => {
// Header values are always credential carriers here (macaroon, rune, basic auth), and
// key-substring matching cannot catch them without also hiding *Path fields.
const config = {
authentication: {
macaroonPath: '/visible/path',
options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef', rune: 'cln-rune', authorization: 'Basic xyz' } }
}
};
const masked = Common.maskPasswords(config);
assert.equal(masked.authentication.options.headers['Grpc-Metadata-macaroon'], '*'.repeat(20));
assert.equal(masked.authentication.options.headers.rune, '*'.repeat(20));
assert.equal(masked.authentication.options.headers.authorization, '*'.repeat(20));
assert.equal(masked.authentication.macaroonPath, '/visible/path');
});
const seedAppConfig = () => {
Common.appConfig = {
defaultNodeIndex: 0,
selectedNodeIndex: 0,
rtlConfFilePath: '/conf',
dbDirectoryPath: '/db',
rtlPass: 'server-hash',
allowPasswordUpdate: true,
enable2FA: true,
secret2FA: 'server-seed',
disableAuth: false,
SSO: { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' },
nodes: []
};
Common.selectedNode = null;
Common.nodes = [];
};
test('addSecureData pins disableAuth and the SSO object to server-held values', () => {
// The settings API must not be able to flip the authentication mode or move SSO fields;
// client-supplied values for these are deployment-level switches, not settings.
seedAppConfig();
const config = Common.addSecureData({
disableAuth: true,
SSO: { rtlSSO: 1, rtlCookiePath: '/client-path', logoutRedirectLink: 'https://client', cookieValue: 'client-cookie' },
secret2FA: 'client-seed',
nodes: []
});
assert.equal(config.disableAuth, false);
assert.deepEqual(config.SSO, { rtlSSO: 0, rtlCookiePath: '/server-cookie', logoutRedirectLink: 'https://server-logout', cookieValue: 'server-cookie' });
// An explicit non-empty seed is the settings UI's enable flow and is honored.
assert.equal(config.secret2FA, 'client-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData restores an omitted TOTP seed and derives enable2FA from the seed', () => {
seedAppConfig();
const config = Common.addSecureData({ nodes: [] });
assert.equal(config.secret2FA, 'server-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData treats an empty seed with 2FA claimed on as an omission', () => {
// The pre-login config response shape carries secret2FA: ''; echoing it must not wipe
// the seed while enable2FA stays on.
seedAppConfig();
const config = Common.addSecureData({ secret2FA: '', enable2FA: true, nodes: [] });
assert.equal(config.secret2FA, 'server-seed');
assert.equal(config.enable2FA, true);
});
test('addSecureData honors an explicit seed wipe only when 2FA is disabled', () => {
// The settings UI's disable flow sends secret2FA: '' together with enable2FA: false.
seedAppConfig();
const config = Common.addSecureData({ secret2FA: '', enable2FA: false, nodes: [] });
assert.equal(config.secret2FA, '');
assert.equal(config.enable2FA, false);
});
test('addSecureData does not pin an undefined multiPassHashed over the persisted one', () => {
// First-boot state of a default install: the file already holds multiPassHashed (the
// boot converted it), but the in-memory appConfig still holds plaintext multiPass and
// no hash. Pinning undefined here would erase the only password from the file on save
// and brick the next boot.
seedAppConfig();
Common.appConfig.multiPassHashed = undefined;
Common.appConfig.multiPass = 'password';
const config = Common.addSecureData({ nodes: [] });
assert.equal(Object.prototype.hasOwnProperty.call(config, 'multiPassHashed'), false);
assert.equal(config.multiPass, 'password');
});
test('addSecureData pins multiPassHashed when the server holds one', () => {
seedAppConfig();
Common.appConfig.multiPassHashed = 'server-hash-value';
const config = Common.addSecureData({ multiPassHashed: 'client-value', nodes: [] });
assert.equal(config.multiPassHashed, 'server-hash-value');
});
test('addSecureData pins allowPasswordUpdate and dbDirectoryPath to server-held values', () => {
// allowPasswordUpdate is false precisely when the password is environment-managed, and
// dbDirectoryPath redirects the runtime database — neither is writable from the UI.
seedAppConfig();
Common.appConfig.allowPasswordUpdate = false;
Common.appConfig.dbDirectoryPath = '/server-db';
const config = Common.addSecureData({ allowPasswordUpdate: true, dbDirectoryPath: '/client-db', nodes: [] });
assert.equal(config.allowPasswordUpdate, false);
assert.equal(config.dbDirectoryPath, '/server-db');
});
test('maskPasswords masks bitcoind rpcauth', () => {
const config = { rpcauth: 'user:salt$hmac', rpcuser: 'user', rpcpassword: 'pass' };
const masked = Common.maskPasswords(config);
assert.equal(masked.rpcauth, '*'.repeat(20));
assert.equal(masked.rpcuser, '*'.repeat(20));
assert.equal(masked.rpcpassword, '*'.repeat(20));
});
test('maskPasswords does not mutate its input', () => {
// Masking a live object must not blank the credentials LN requests authenticate with.
const config = { authentication: { options: { headers: { 'Grpc-Metadata-macaroon': 'deadbeef' } } } };
Common.maskPasswords(config);
assert.equal(config.authentication.options.headers['Grpc-Metadata-macaroon'], 'deadbeef');
});