mirror of
https://github.com/Ride-The-Lightning/RTL.git
synced 2026-08-13 12:33:07 +02:00
Contain backup file reads and harden config persistence
This commit is contained in:
parent
72361b8d1b
commit
4514ca1a9b
9 changed files with 306 additions and 80 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import * as fs from 'fs';
|
||||
import { sep } from 'path';
|
||||
import { resolve, sep } from 'path';
|
||||
import ini from 'ini';
|
||||
import parseHocon from 'hocon-parser';
|
||||
import request from '../../utils/request.js';
|
||||
|
|
@ -76,7 +76,23 @@ export const getCurrencyRates = (req, res, next) => {
|
|||
};
|
||||
export const getFile = (req, res, next) => {
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' });
|
||||
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak');
|
||||
const channelBackupPath = req.session.selectedNode.settings.channelBackupPath;
|
||||
let file = '';
|
||||
if (req.query.path) {
|
||||
// The UI only ever requests channel backup files; contain caller paths to the node's
|
||||
// backup directory so this endpoint cannot read the config, macaroons or the SSO
|
||||
// cookie (getConfig serves the config file masked; this must not bypass that).
|
||||
const resolved = resolve(req.query.path);
|
||||
if (resolved !== resolve(channelBackupPath) && !resolved.startsWith(resolve(channelBackupPath) + sep)) {
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'WARN', fileName: 'RTLConf', msg: 'Blocked file read outside the channel backup directory', data: req.query.path });
|
||||
const err = common.handleError({ statusCode: 403, message: 'Reading File Error', error: 'File path is outside the channel backup directory' }, 'RTLConf', 'Reading File Error', req.session.selectedNode);
|
||||
return res.status(err.statusCode).json({ message: err.message, error: err.error });
|
||||
}
|
||||
file = resolved;
|
||||
}
|
||||
else {
|
||||
file = channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak';
|
||||
}
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Channel Point', data: req.query.channel });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file });
|
||||
fs.readFile(file, 'utf8', (errRes, data) => {
|
||||
|
|
@ -307,9 +323,12 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
delete node.authentication?.options;
|
||||
delete node.authentication?.runeValue;
|
||||
});
|
||||
// Persist first and only then adopt the new runtime config, so a failed write
|
||||
// (read-only volume, ENOSPC) cannot leave the process diverged from the file.
|
||||
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
// Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
|
||||
// config) and only then adopt the new runtime config, so a failed write leaves the
|
||||
// process on the old one.
|
||||
const tempConfigFile = RTLConfFile + '.tmp';
|
||||
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
fs.renameSync(tempConfigFile, RTLConfFile);
|
||||
common.appConfig = newAppConfig;
|
||||
// removeSecureData clones, so the runtime config is untouched; it strips rtlPass,
|
||||
// the TOTP seed, the SSO cookie and all per-node credentials symmetrically.
|
||||
|
|
|
|||
|
|
@ -22,32 +22,37 @@ export class CommonService {
|
|||
{ name: 'JUL', days: 31 }, { name: 'AUG', days: 31 }, { name: 'SEP', days: 30 }, { name: 'OCT', days: 31 }, { name: 'NOV', days: 30 }, { name: 'DEC', days: 31 }
|
||||
];
|
||||
this.maskPasswords = (obj) => {
|
||||
const keys = Object.keys(obj);
|
||||
const length = keys.length;
|
||||
if (length !== 0) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Header maps always carry credentials in this codebase (macaroon, rune, basic
|
||||
// auth). Key-substring matching cannot catch them without also hiding the *Path
|
||||
// fields the settings UI legitimately shows, so mask the whole map.
|
||||
if (keys[i] === 'headers' && obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
Object.keys(obj[keys[i]]).forEach((headerKey) => { obj[keys[i]][headerKey] = '*'.repeat(20); });
|
||||
}
|
||||
else if (obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
// Truthiness guard: null is 'object' too, and the recursive call mutates in
|
||||
// place — assigning into keys[] here clobbered the key list for numeric keys.
|
||||
this.maskPasswords(obj[keys[i]]);
|
||||
}
|
||||
if (typeof keys[i] === 'string' &&
|
||||
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
|
||||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
|
||||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue') || keys[i].toLowerCase().includes('rtlpass') ||
|
||||
keys[i].toLowerCase().includes('runevalue'))) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
// Clone up front: masking a live config object must not blank the credentials LN
|
||||
// requests authenticate with (mirrors removeSecureData).
|
||||
const masked = JSON.parse(JSON.stringify(obj));
|
||||
const maskRecursive = (current) => {
|
||||
const keys = Object.keys(current);
|
||||
const length = keys.length;
|
||||
if (length !== 0) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Header maps always carry credentials in this codebase (macaroon, rune, basic
|
||||
// auth). Key-substring matching cannot catch them without also hiding the *Path
|
||||
// fields the settings UI legitimately shows, so mask the whole map.
|
||||
if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
|
||||
Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); });
|
||||
}
|
||||
else if (current[keys[i]] && typeof current[keys[i]] === 'object') {
|
||||
// Truthiness guard: null is 'object' too and must not reach Object.keys.
|
||||
maskRecursive(current[keys[i]]);
|
||||
}
|
||||
if (typeof keys[i] === 'string' &&
|
||||
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
|
||||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
|
||||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') ||
|
||||
keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') ||
|
||||
keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))) {
|
||||
current[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
return current;
|
||||
};
|
||||
return maskRecursive(masked);
|
||||
};
|
||||
this.removeAuthSecureData = (node) => {
|
||||
if (node.authentication) {
|
||||
|
|
@ -78,20 +83,31 @@ export class CommonService {
|
|||
this.addSecureData = (config) => {
|
||||
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
|
||||
config.rtlPass = this.appConfig.rtlPass;
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
// Pin the hash only when the server holds one: on a default install's first boot the
|
||||
// file already has multiPassHashed but the in-memory config does not, and pinning
|
||||
// undefined would erase the only password from the file on save, bricking the boot.
|
||||
if (this.appConfig.multiPassHashed) {
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
}
|
||||
else {
|
||||
delete config.multiPassHashed;
|
||||
}
|
||||
// Deployment-level switches are pinned to server-held values: the settings API must
|
||||
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, and no UI
|
||||
// flow writes them. Pinning the whole object also means a trimmed or missing SSO
|
||||
// object can never wipe server state.
|
||||
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, the
|
||||
// password policy, or the database location; no UI flow writes them. Pinning the
|
||||
// whole SSO object also means a trimmed or missing SSO object can never wipe server
|
||||
// state.
|
||||
config.disableAuth = this.appConfig.disableAuth;
|
||||
config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate;
|
||||
config.dbDirectoryPath = this.appConfig.dbDirectoryPath;
|
||||
config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {}));
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
// Restore the TOTP seed when the client omits it — and when it sends an empty seed
|
||||
// while still claiming 2FA is on (the pre-login config response carries that shape).
|
||||
// An explicit non-empty seed is the settings UI's enable flow and is honored; an
|
||||
// empty seed with enable2FA false is its disable flow and is honored too.
|
||||
// while still claiming 2FA is on (an inconsistent pair no honest flow produces).
|
||||
// The settings UI's enable flow sends a non-empty seed; its disable flow sends an
|
||||
// empty seed with enable2FA false. Both are honored.
|
||||
if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) {
|
||||
config.secret2FA = this.appConfig.secret2FA;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ export class ConfigService {
|
|||
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
|
||||
// maskPasswords keeps paths visible for debugging while redacting credential
|
||||
// fields such as lnApiPassword before they reach the log file.
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_file || ''), '', () => { });
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ this release should add its entry under the appropriate section below.
|
|||
- **Config & logging: reduce exposure of authentication secrets**
|
||||
([#1659](https://github.com/Ride-The-Lightning/RTL/pull/1659)).
|
||||
Tightens redaction of authentication material in node logs and configuration API
|
||||
responses, and keeps runtime-only SSO state out of the persisted config file. Adds
|
||||
regression coverage (`test/backend/common.test.mjs`). Users are encouraged to update
|
||||
promptly.
|
||||
responses, pins deployment-level authentication settings server-side, contains backup
|
||||
file downloads to the node's backup directory, and hardens the settings persistence
|
||||
path. Adds regression coverage (`test/backend/common.test.mjs`). Users are encouraged
|
||||
to update promptly.
|
||||
|
||||
## Code Health
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import * as fs from 'fs';
|
||||
import { sep } from 'path';
|
||||
import { resolve, sep } from 'path';
|
||||
import ini from 'ini';
|
||||
import parseHocon from 'hocon-parser';
|
||||
import request from '../../utils/request.js';
|
||||
|
|
@ -81,7 +81,22 @@ export const getCurrencyRates = (req, res, next) => {
|
|||
|
||||
export const getFile = (req, res, next) => {
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' });
|
||||
const file = req.query.path ? req.query.path : (req.session.selectedNode.settings.channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak');
|
||||
const channelBackupPath = req.session.selectedNode.settings.channelBackupPath;
|
||||
let file = '';
|
||||
if (req.query.path) {
|
||||
// The UI only ever requests channel backup files; contain caller paths to the node's
|
||||
// backup directory so this endpoint cannot read the config, macaroons or the SSO
|
||||
// cookie (getConfig serves the config file masked; this must not bypass that).
|
||||
const resolved = resolve(req.query.path);
|
||||
if (resolved !== resolve(channelBackupPath) && !resolved.startsWith(resolve(channelBackupPath) + sep)) {
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'WARN', fileName: 'RTLConf', msg: 'Blocked file read outside the channel backup directory', data: req.query.path });
|
||||
const err = common.handleError({ statusCode: 403, message: 'Reading File Error', error: 'File path is outside the channel backup directory' }, 'RTLConf', 'Reading File Error', req.session.selectedNode);
|
||||
return res.status(err.statusCode).json({ message: err.message, error: err.error });
|
||||
}
|
||||
file = resolved;
|
||||
} else {
|
||||
file = channelBackupPath + sep + 'channel-' + req.query.channel?.replace(':', '-') + '.bak';
|
||||
}
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'Channel Point', data: req.query.channel });
|
||||
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file });
|
||||
fs.readFile(file, 'utf8', (errRes, data) => {
|
||||
|
|
@ -305,9 +320,12 @@ export const updateApplicationSettings = (req, res, next) => {
|
|||
delete node.authentication?.options;
|
||||
delete node.authentication?.runeValue;
|
||||
});
|
||||
// Persist first and only then adopt the new runtime config, so a failed write
|
||||
// (read-only volume, ENOSPC) cannot leave the process diverged from the file.
|
||||
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
// Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
|
||||
// config) and only then adopt the new runtime config, so a failed write leaves the
|
||||
// process on the old one.
|
||||
const tempConfigFile = RTLConfFile + '.tmp';
|
||||
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
|
||||
fs.renameSync(tempConfigFile, RTLConfFile);
|
||||
common.appConfig = newAppConfig;
|
||||
// removeSecureData clones, so the runtime config is untouched; it strips rtlPass,
|
||||
// the TOTP seed, the SSO cookie and all per-node credentials symmetrically.
|
||||
|
|
|
|||
|
|
@ -27,32 +27,37 @@ export class CommonService {
|
|||
constructor() {}
|
||||
|
||||
public maskPasswords = (obj) => {
|
||||
const keys = Object.keys(obj);
|
||||
const length = keys.length;
|
||||
if (length !== 0) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Header maps always carry credentials in this codebase (macaroon, rune, basic
|
||||
// auth). Key-substring matching cannot catch them without also hiding the *Path
|
||||
// fields the settings UI legitimately shows, so mask the whole map.
|
||||
if (keys[i] === 'headers' && obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
Object.keys(obj[keys[i]]).forEach((headerKey) => { obj[keys[i]][headerKey] = '*'.repeat(20); });
|
||||
} else if (obj[keys[i]] && typeof obj[keys[i]] === 'object') {
|
||||
// Truthiness guard: null is 'object' too, and the recursive call mutates in
|
||||
// place — assigning into keys[] here clobbered the key list for numeric keys.
|
||||
this.maskPasswords(obj[keys[i]]);
|
||||
}
|
||||
if (typeof keys[i] === 'string' &&
|
||||
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
|
||||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
|
||||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('secret2fa') ||
|
||||
keys[i].toLowerCase().includes('cookievalue') || keys[i].toLowerCase().includes('rtlpass') ||
|
||||
keys[i].toLowerCase().includes('runevalue'))
|
||||
) {
|
||||
obj[keys[i]] = '*'.repeat(20);
|
||||
// Clone up front: masking a live config object must not blank the credentials LN
|
||||
// requests authenticate with (mirrors removeSecureData).
|
||||
const masked = JSON.parse(JSON.stringify(obj));
|
||||
const maskRecursive = (current) => {
|
||||
const keys = Object.keys(current);
|
||||
const length = keys.length;
|
||||
if (length !== 0) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Header maps always carry credentials in this codebase (macaroon, rune, basic
|
||||
// auth). Key-substring matching cannot catch them without also hiding the *Path
|
||||
// fields the settings UI legitimately shows, so mask the whole map.
|
||||
if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
|
||||
Object.keys(current[keys[i]]).forEach((headerKey) => { current[keys[i]][headerKey] = '*'.repeat(20); });
|
||||
} else if (current[keys[i]] && typeof current[keys[i]] === 'object') {
|
||||
// Truthiness guard: null is 'object' too and must not reach Object.keys.
|
||||
maskRecursive(current[keys[i]]);
|
||||
}
|
||||
if (typeof keys[i] === 'string' &&
|
||||
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') ||
|
||||
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') ||
|
||||
keys[i].toLowerCase().includes('rpcuser') || keys[i].toLowerCase().includes('rpcauth') ||
|
||||
keys[i].toLowerCase().includes('secret2fa') || keys[i].toLowerCase().includes('cookievalue') ||
|
||||
keys[i].toLowerCase().includes('rtlpass') || keys[i].toLowerCase().includes('runevalue'))
|
||||
) {
|
||||
current[keys[i]] = '*'.repeat(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
return current;
|
||||
};
|
||||
return maskRecursive(masked);
|
||||
};
|
||||
|
||||
public removeAuthSecureData = (node: SelectedNode) => {
|
||||
|
|
@ -84,20 +89,30 @@ export class CommonService {
|
|||
public addSecureData = (config: ApplicationConfig) => {
|
||||
config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
|
||||
config.rtlPass = this.appConfig.rtlPass;
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
// Pin the hash only when the server holds one: on a default install's first boot the
|
||||
// file already has multiPassHashed but the in-memory config does not, and pinning
|
||||
// undefined would erase the only password from the file on save, bricking the boot.
|
||||
if (this.appConfig.multiPassHashed) {
|
||||
config.multiPassHashed = this.appConfig.multiPassHashed;
|
||||
} else {
|
||||
delete config.multiPassHashed;
|
||||
}
|
||||
// Deployment-level switches are pinned to server-held values: the settings API must
|
||||
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, and no UI
|
||||
// flow writes them. Pinning the whole object also means a trimmed or missing SSO
|
||||
// object can never wipe server state.
|
||||
// not flip the authentication mode (disableAuth, SSO) or move SSO fields, the
|
||||
// password policy, or the database location; no UI flow writes them. Pinning the
|
||||
// whole SSO object also means a trimmed or missing SSO object can never wipe server
|
||||
// state.
|
||||
config.disableAuth = this.appConfig.disableAuth;
|
||||
config.allowPasswordUpdate = this.appConfig.allowPasswordUpdate;
|
||||
config.dbDirectoryPath = this.appConfig.dbDirectoryPath;
|
||||
config.SSO = JSON.parse(JSON.stringify(this.appConfig.SSO || {}));
|
||||
if (this.appConfig.multiPass) {
|
||||
config.multiPass = this.appConfig.multiPass;
|
||||
}
|
||||
// Restore the TOTP seed when the client omits it — and when it sends an empty seed
|
||||
// while still claiming 2FA is on (the pre-login config response carries that shape).
|
||||
// An explicit non-empty seed is the settings UI's enable flow and is honored; an
|
||||
// empty seed with enable2FA false is its disable flow and is honored too.
|
||||
// while still claiming 2FA is on (an inconsistent pair no honest flow produces).
|
||||
// The settings UI's enable flow sends a non-empty seed; its disable flow sends an
|
||||
// empty seed with enable2FA false. Both are honored.
|
||||
if (config.secret2FA === undefined || (config.secret2FA === '' && config.enable2FA)) {
|
||||
config.secret2FA = this.appConfig.secret2FA;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ export class ConfigService {
|
|||
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
|
||||
// maskPasswords keeps paths visible for debugging while redacting credential
|
||||
// fields such as lnApiPassword before they reach the log file.
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(JSON.parse(JSON.stringify(this.common.nodes[idx])))) });
|
||||
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.maskPasswords(this.common.nodes[idx])) });
|
||||
const log_file = this.common.nodes[idx].settings.logFile;
|
||||
if (fs.existsSync(log_file || '')) {
|
||||
fs.writeFile((log_file || ''), '', () => { });
|
||||
|
|
|
|||
|
|
@ -161,3 +161,49 @@ test('addSecureData honors an explicit seed wipe only when 2FA is disabled', ()
|
|||
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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, sep } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { updateApplicationSettings } from '../../backend/controllers/shared/RTLConf.js';
|
||||
import { updateApplicationSettings, getFile } from '../../backend/controllers/shared/RTLConf.js';
|
||||
import { Common } from '../../backend/utils/common.js';
|
||||
import { WSServer } from '../../backend/utils/webSocketServer.js';
|
||||
|
||||
|
|
@ -329,3 +329,114 @@ test('updateApplicationSettings tolerates a request body without an SSO object',
|
|||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('updateApplicationSettings leaves the runtime config untouched when the file write fails', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-writefail-'));
|
||||
const confPath = join(tempDir, 'RTL-Config.json');
|
||||
const oldConfig = {
|
||||
defaultNodeIndex: 0,
|
||||
dbDirectoryPath: '/db',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' },
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
lnNode: 'lnd-main',
|
||||
lnImplementation: 'LND',
|
||||
authentication: { macaroonPath: '/lnd/admin' },
|
||||
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
|
||||
}
|
||||
]
|
||||
};
|
||||
const runtimeConfig = clone({
|
||||
...oldConfig,
|
||||
selectedNodeIndex: 0,
|
||||
rtlConfFilePath: tempDir,
|
||||
rtlPass: 'hashed-password',
|
||||
secret2FA: 'live-totp-seed',
|
||||
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
|
||||
});
|
||||
const requestBody = {
|
||||
...clone(oldConfig),
|
||||
selectedNodeIndex: 0,
|
||||
SSO: { rtlSSO: 0 },
|
||||
nodes: [{ ...clone(oldConfig.nodes[0]), settings: { themeMode: 'NIGHT' } }]
|
||||
};
|
||||
|
||||
try {
|
||||
Common.appConfig = clone(runtimeConfig);
|
||||
Common.nodes = clone(runtimeConfig.nodes);
|
||||
Common.selectedNode = Common.nodes[0];
|
||||
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
|
||||
chmodSync(tempDir, 0o555); // read-only dir: temp-file create and rename both fail
|
||||
|
||||
let responseStatus = null;
|
||||
updateApplicationSettings(
|
||||
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
|
||||
{
|
||||
status: (status) => {
|
||||
responseStatus = status;
|
||||
return { json: () => {} };
|
||||
}
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(responseStatus, 500);
|
||||
// The failed write must not have committed the prospective config in memory either.
|
||||
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
|
||||
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
|
||||
assert.equal(Common.appConfig.nodes[0].settings.themeMode, 'DAY');
|
||||
// And the on-disk file still parses as the pre-call config.
|
||||
const onDisk = JSON.parse(readFileSync(confPath, 'utf-8'));
|
||||
assert.equal(onDisk.nodes.length, 1);
|
||||
} finally {
|
||||
clearInterval(WSServer.pingInterval);
|
||||
chmodSync(tempDir, 0o755);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('getFile contains caller paths to the channel backup directory', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-getfile-'));
|
||||
const backupDir = join(tempDir, 'backups');
|
||||
mkdirSync(backupDir);
|
||||
writeFileSync(join(tempDir, 'secret.bak'), 'top-secret', 'utf-8');
|
||||
writeFileSync(join(backupDir, 'channel-1x2x3.bak'), 'backup-data', 'utf-8');
|
||||
const session = { selectedNode: { lnImplementation: 'LND', settings: { channelBackupPath: backupDir } } };
|
||||
const mockRes = () => {
|
||||
const res = { statusCode: null, body: null };
|
||||
res.status = (code) => {
|
||||
res.statusCode = code;
|
||||
return { json: (body) => { res.body = body; } };
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
try {
|
||||
// An escaping path is rejected before any read.
|
||||
const rejected = mockRes();
|
||||
getFile({ query: { path: join(tempDir, 'secret.bak') }, session }, rejected, null);
|
||||
assert.equal(rejected.statusCode, 403);
|
||||
|
||||
// A contained path is served.
|
||||
const served = mockRes();
|
||||
await new Promise((resolve) => {
|
||||
const res = { status: (code) => { served.statusCode = code; return { json: (body) => { served.body = body; resolve(); } }; } };
|
||||
getFile({ query: { path: join(backupDir, 'channel-1x2x3.bak') }, session }, res, null);
|
||||
});
|
||||
assert.equal(served.statusCode, 200);
|
||||
assert.equal(served.body, 'backup-data');
|
||||
|
||||
// A contained but missing file returns a path-free error (the ENOENT branch).
|
||||
const missing = mockRes();
|
||||
await new Promise((resolve) => {
|
||||
const res = { status: (code) => { missing.statusCode = code; return { json: (body) => { missing.body = body; resolve(); } }; } };
|
||||
getFile({ query: { path: join(backupDir, 'channel-missing.bak') }, session }, res, null);
|
||||
});
|
||||
assert.equal(missing.statusCode, 500);
|
||||
assert.equal(JSON.stringify(missing.body).includes(backupDir), false);
|
||||
} finally {
|
||||
clearInterval(WSServer.pingInterval);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue