diff --git a/backend/controllers/shared/RTLConf.js b/backend/controllers/shared/RTLConf.js index 9a0b5e5a..3c2aa420 100644 --- a/backend/controllers/shared/RTLConf.js +++ b/backend/controllers/shared/RTLConf.js @@ -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. diff --git a/backend/utils/common.js b/backend/utils/common.js index 8d87cb68..5ffd6a11 100644 --- a/backend/utils/common.js +++ b/backend/utils/common.js @@ -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; } diff --git a/backend/utils/config.js b/backend/utils/config.js index da7f7d47..36ecca30 100644 --- a/backend/utils/config.js +++ b/backend/utils/config.js @@ -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 || ''), '', () => { }); diff --git a/release-notes/Release-notes-0.15.10.md b/release-notes/Release-notes-0.15.10.md index 7d25b6f1..a0c55a65 100644 --- a/release-notes/Release-notes-0.15.10.md +++ b/release-notes/Release-notes-0.15.10.md @@ -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 diff --git a/server/controllers/shared/RTLConf.ts b/server/controllers/shared/RTLConf.ts index 32625f11..96038ece 100644 --- a/server/controllers/shared/RTLConf.ts +++ b/server/controllers/shared/RTLConf.ts @@ -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. diff --git a/server/utils/common.ts b/server/utils/common.ts index 55769c09..a3b452f3 100644 --- a/server/utils/common.ts +++ b/server/utils/common.ts @@ -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; } diff --git a/server/utils/config.ts b/server/utils/config.ts index 62cbb78d..30b73c59 100644 --- a/server/utils/config.ts +++ b/server/utils/config.ts @@ -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 || ''), '', () => { }); diff --git a/test/backend/common.test.mjs b/test/backend/common.test.mjs index 1054b530..5b49f5bd 100644 --- a/test/backend/common.test.mjs +++ b/test/backend/common.test.mjs @@ -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'); +}); diff --git a/test/backend/rtlconf.test.mjs b/test/backend/rtlconf.test.mjs index e33bb5e3..f1ec33c6 100644 --- a/test/backend/rtlconf.test.mjs +++ b/test/backend/rtlconf.test.mjs @@ -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 }); + } +});