Contain backup file reads and harden config persistence

This commit is contained in:
saubyk 2026-08-03 14:56:58 -07:00
parent 72361b8d1b
commit 4514ca1a9b
No known key found for this signature in database
GPG key ID: 00C9E2BC2E45666F
9 changed files with 306 additions and 80 deletions

View file

@ -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.

View file

@ -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;
}

View file

@ -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 || ''), '', () => { });

View 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

View file

@ -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.

View file

@ -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;
}

View file

@ -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 || ''), '', () => { });

View 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');
});

View file

@ -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 });
}
});