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>
This commit is contained in:
Suheb 2026-08-03 22:49:14 -07:00 committed by GitHub
parent f48a647272
commit a005b687a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1878 additions and 1596 deletions

View file

@ -91,8 +91,10 @@ Eclair, wired to RTL — for end-to-end testing across all three implementations
`docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only; `docker/README.md`, or the `rtl-docker-fixture` skill in `.claude/skills/`. It is dev-only;
every credential in it is throwaway. every credential in it is throwaway.
Backend code has no unit-test harness; `npm run test` runs the frontend Karma/Jasmine specs. Backend regression tests live in `test/backend/` (plain `node:test`, run against the
For backend changes, verify against the fixture and say so in the PR. compiled `backend/`). `npm run test` compiles the backend, then runs them
(`npm run testbackend`) before the frontend Karma/Jasmine specs, so they never test stale
code. For backend changes, also verify against the fixture and say so in the PR.
## Conventions ## Conventions

View file

@ -53,7 +53,10 @@ export const getChannels = (req, res, next) => {
options.form = req.query; options.form = req.query;
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form });
} }
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options }); // Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options
// object carries the node's lnApiPassword in its authorization header, and node logs are
// routinely shared when debugging.
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } });
if (common.read_dummy_data) { if (common.read_dummy_data) {
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); }); common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
} }

View file

@ -4,10 +4,10 @@ import { Common } from '../../utils/common.js';
let options = null; let options = null;
const logger = Logger; const logger = Logger;
const common = Common; const common = Common;
export const getAliasForChannel = (selNode, channel) => { export const getAliasForChannel = (selNode, channel, requestOptions) => {
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : ''; const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((aliasBody) => { return request(requestOptions).then((aliasBody) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias }); logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias });
channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20); channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20);
return channel; return channel;
@ -30,18 +30,26 @@ export const getAllChannels = (req, res, next) => {
request(options).then((body) => { request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body });
if (body.channels) { if (body.channels) {
return Promise.all(body.channels?.map((channel) => { body.channels.forEach((channel) => {
local = (channel.local_balance) ? +channel.local_balance : 0; local = (channel.local_balance) ? +channel.local_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0; remote = (channel.remote_balance) ? +channel.remote_balance : 0;
total = local + remote; total = local + remote;
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3); channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
return getAliasForChannel(req.session.selectedNode, channel); });
})).then((values) => { const selNode = req.session.selectedNode;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body }); const { qs: _qs, ...requestOptions } = options;
return res.status(200).json(body); const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
}).catch((errRes) => { common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => {
const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode); try {
return res.status(err.statusCode).json({ message: err.message, error: err.error }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message });
}
}
}); });
} }
else { else {
@ -66,26 +74,32 @@ export const getPendingChannels = (req, res, next) => {
if (!body.total_limbo_balance) { if (!body.total_limbo_balance) {
body.total_limbo_balance = 0; body.total_limbo_balance = 0;
} }
const promises = []; const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getPendingAliasesTasks = [];
if (body.pending_open_channels && body.pending_open_channels.length > 0) { if (body.pending_open_channels && body.pending_open_channels.length > 0) {
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) { if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) { if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) { if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
return Promise.all(promises).then((values) => { common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body }); try {
return res.status(200).json(body); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
}). return res.status(200).json(body);
catch((errRes) => { }
const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode); catch (e) {
return res.status(err.statusCode).json({ message: err.message, error: err.error }); logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message });
}
}
}); });
}).catch((errRes) => { }).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode); const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
@ -102,15 +116,23 @@ export const getClosedChannels = (req, res, next) => {
options.qs = req.query; options.qs = req.query;
request(options).then((body) => { request(options).then((body) => {
if (body.channels && body.channels.length > 0) { if (body.channels && body.channels.length > 0) {
return Promise.all(body.channels?.map((channel) => { body.channels.forEach((channel) => {
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type; channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
return getAliasForChannel(req.session.selectedNode, channel); });
})).then((values) => { const selNode = req.session.selectedNode;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body }); const { qs: _qs, ...requestOptions } = options;
return res.status(200).json(body); const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
}).catch((errRes) => { common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => {
const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode); try {
return res.status(err.statusCode).json({ message: err.message, error: err.error }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
return res.status(200).json(body);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message });
}
}
}); });
} }
else { else {

View file

@ -4,9 +4,9 @@ import { Common } from '../../utils/common.js';
let options = null; let options = null;
const logger = Logger; const logger = Logger;
const common = Common; const common = Common;
export const getAliasFromPubkey = (selNode, pubkey) => { export const getAliasFromPubkey = (selNode, pubkey, requestOptions) => {
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((res) => { return request(requestOptions).then((res) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias }); logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
return res.node.alias; return res.node.alias;
}). }).
@ -83,19 +83,25 @@ export const getQueryRoutes = (req, res, next) => {
request(options).then((body) => { request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body });
if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) { if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) {
return Promise.all(body.routes[0].hops?.map((hop) => getAliasFromPubkey(req.session.selectedNode, hop.pub_key))). const selNode = req.session.selectedNode;
then((values) => { const { qs: _qs, ...requestOptions } = options;
body.routes[0].hops?.map((hop, i) => { const getRouteAliasesTasks = body.routes[0].hops.map((hop) => () => getAliasFromPubkey(selNode, hop.pub_key, { ...requestOptions }));
hop.hop_sequence = i + 1; common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, (values) => {
hop.pubkey_alias = values[i]; try {
return hop; body.routes[0].hops?.map((hop, i) => {
}); hop.hop_sequence = i + 1;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body }); hop.pubkey_alias = typeof values[i] === 'string' ? values[i] : 'Unknown';
res.status(200).json(body); return hop;
}). });
catch((errRes) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body });
const err = common.handleError(errRes, 'Graph', 'Get Query Routes Error', req.session.selectedNode); res.status(200).json(body);
return res.status(err.statusCode).json({ message: err.message, error: err.error }); }
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Query Routes Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Query Routes Error', error: e.message });
}
}
}); });
} }
else { else {
@ -145,14 +151,21 @@ export const getAliasesForPubkeys = (req, res, next) => {
} }
if (req.query.pubkeys) { if (req.query.pubkeys) {
const pubkeyArr = req.query.pubkeys.split(','); const pubkeyArr = req.query.pubkeys.split(',');
return Promise.all(pubkeyArr?.map((pubkey) => getAliasFromPubkey(req.session.selectedNode, pubkey))). const selNode = req.session.selectedNode;
then((values) => { const { qs: _qs, ...requestOptions } = options;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: values }); const getAliasesTasks = pubkeyArr.map((pubkey) => () => getAliasFromPubkey(selNode, pubkey, { ...requestOptions }));
res.status(200).json(values); common.runWithConcurrencyLimit(getAliasesTasks, 20, (values) => {
}). try {
catch((errRes) => { const safeValues = values.map((v) => (typeof v === 'string' ? v : 'Unknown'));
const err = common.handleError(errRes, 'Graph', 'Get Aliases for Pubkeys Error', req.session.selectedNode); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: safeValues });
return res.status(err.statusCode).json({ message: err.message, error: err.error }); res.status(200).json(safeValues);
}
catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Aliases for Pubkeys Error', error: e.message });
if (!res.headersSent) {
res.status(500).json({ message: 'Get Aliases for Pubkeys Error', error: e.message });
}
}
}); });
} }
else { else {

View file

@ -1,6 +1,6 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import * as fs from 'fs'; import * as fs from 'fs';
import { sep } from 'path'; import { resolve, sep } from 'path';
import ini from 'ini'; import ini from 'ini';
import parseHocon from 'hocon-parser'; import parseHocon from 'hocon-parser';
import request from '../../utils/request.js'; import request from '../../utils/request.js';
@ -76,7 +76,23 @@ export const getCurrencyRates = (req, res, next) => {
}; };
export const getFile = (req, res, next) => { export const getFile = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' }); 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: 'Channel Point', data: req.query.channel });
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file });
fs.readFile(file, 'utf8', (errRes, data) => { fs.readFile(file, 'utf8', (errRes, data) => {
@ -89,7 +105,8 @@ export const getFile = (req, res, next) => {
return res.status(err.statusCode).json({ message: err.error, error: err.error }); return res.status(err.statusCode).json({ message: err.error, error: err.error });
} }
else { else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data }); // File contents can carry node credentials; never write them to the log.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' });
res.status(200).json(data); res.status(200).json(data);
} }
}); });
@ -109,7 +126,6 @@ export const getApplicationSettings = (req, res, next) => {
delete appConfData.SSO.rtlCookiePath; delete appConfData.SSO.rtlCookiePath;
delete appConfData.SSO.cookieValue; delete appConfData.SSO.cookieValue;
delete appConfData.SSO.logoutRedirectLink; delete appConfData.SSO.logoutRedirectLink;
appConfData.secret2FA = '';
appConfData.dbDirectoryPath = ''; appConfData.dbDirectoryPath = '';
appConfData.nodes[selNodeIdx].authentication = new Authentication(); appConfData.nodes[selNodeIdx].authentication = new Authentication();
delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath; delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath;
@ -201,7 +217,12 @@ export const updateNodeSettings = (req, res, next) => {
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index)); const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
if (node && node.settings) { if (node && node.settings) {
// channelBackupPath anchors getFile's containment root and is documented as a
// config-file-only setting; accepting it from the API would let the caller being
// contained choose the containment base. Pin it to the server-held value.
const serverChannelBackupPath = node.settings.channelBackupPath;
node.settings = { ...node.settings, ...req.body.settings }; node.settings = { ...node.settings, ...req.body.settings };
node.settings.channelBackupPath = serverChannelBackupPath;
if (node.authentication && req.body.authentication) { if (node.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) { if (req.body.authentication.boltzMacaroonPath) {
node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
@ -220,7 +241,9 @@ export const updateNodeSettings = (req, res, next) => {
fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8');
const selectedNode = common.findNode(req.session.selectedNode.index); const selectedNode = common.findNode(req.session.selectedNode.index);
if (selectedNode && selectedNode.settings) { if (selectedNode && selectedNode.settings) {
const serverChannelBackupPath = selectedNode.settings.channelBackupPath;
selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; selectedNode.settings = { ...selectedNode.settings, ...req.body.settings };
selectedNode.settings.channelBackupPath = serverChannelBackupPath;
if (selectedNode.authentication && req.body.authentication) { if (selectedNode.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) { if (req.body.authentication.boltzMacaroonPath) {
selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
@ -281,7 +304,7 @@ export const updateApplicationSettings = (req, res, next) => {
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode))); const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes]; runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
} }
common.appConfig = JSON.parse(JSON.stringify({ const newAppConfig = JSON.parse(JSON.stringify({
...runtimeConfig, ...runtimeConfig,
selectedNodeIndex: config.selectedNodeIndex !== undefined ? selectedNodeIndex: config.selectedNodeIndex !== undefined ?
config.selectedNodeIndex : common.appConfig.selectedNodeIndex, config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
@ -292,21 +315,42 @@ export const updateApplicationSettings = (req, res, next) => {
rtlConfFilePath: common.appConfig.rtlConfFilePath, rtlConfFilePath: common.appConfig.rtlConfFilePath,
rtlPass: common.appConfig.rtlPass rtlPass: common.appConfig.rtlPass
})); }));
const fileConfig = JSON.parse(JSON.stringify(common.appConfig)); const fileConfig = JSON.parse(JSON.stringify(newAppConfig));
delete fileConfig.selectedNodeIndex; delete fileConfig.selectedNodeIndex;
delete fileConfig.enable2FA; delete fileConfig.enable2FA;
delete fileConfig.allowPasswordUpdate; delete fileConfig.allowPasswordUpdate;
delete fileConfig.rtlConfFilePath; delete fileConfig.rtlConfFilePath;
delete fileConfig.rtlPass; delete fileConfig.rtlPass;
delete fileConfig.multiPass; delete fileConfig.multiPass;
// Runtime-only SSO bearer; must not be persisted with the config.
if (fileConfig.SSO) {
delete fileConfig.SSO.cookieValue;
}
fileConfig.nodes?.forEach((node) => { fileConfig.nodes?.forEach((node) => {
delete node.authentication?.options; delete node.authentication?.options;
delete node.authentication?.runeValue; delete node.authentication?.runeValue;
}); });
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); // Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
const newConfig = JSON.parse(JSON.stringify(common.appConfig)); // config) and only then adopt the new runtime config, so a failed write leaves the
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.maskPasswords(newConfig) }); // process on the old one. The temp file inherits the existing file's mode so a
res.status(201).json(common.removeSecureData(newConfig)); // hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and
// single-file bind mounts cannot be renamed over — fall back to an in-place write,
// which preserves inode and mode.
const tempConfigFile = RTLConfFile + '.tmp';
try {
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600);
fs.renameSync(tempConfigFile, RTLConfFile);
}
catch {
fs.rmSync(tempConfigFile, { force: true, recursive: true });
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
}
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.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) });
res.status(201).json(common.removeSecureData(newAppConfig));
} }
catch (errRes) { catch (errRes) {
const errMsg = 'Update Default Node Error'; const errMsg = 'Update Default Node Error';

View file

@ -19,6 +19,9 @@ const loginInterval = setInterval(() => {
} }
} }
}, LOCKING_PERIOD); }, LOCKING_PERIOD);
// The sweeper must not hold the event loop open on its own (it would keep
// `node --test` or a CLI invocation alive for the full 30-minute period).
loginInterval.unref();
export const getFailedInfo = (reqIP, currentTime) => { export const getFailedInfo = (reqIP, currentTime) => {
let failed = { count: 0, lastTried: currentTime }; let failed = { count: 0, lastTried: currentTime };
if ((!failedLoginAttempts[reqIP]) || (currentTime > (failed.lastTried + LOCKING_PERIOD))) { if ((!failedLoginAttempts[reqIP]) || (currentTime > (failed.lastTried + LOCKING_PERIOD))) {
@ -45,6 +48,21 @@ const handleMultipleFailedAttemptsError = (failed, currentTime, errMsg) => {
} }
}; };
export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && otplib.authenticator.check(twoFAToken, common.appConfig.secret2FA)); export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && otplib.authenticator.check(twoFAToken, common.appConfig.secret2FA));
// Mirrors isAuthenticated: a request carrying a valid session JWT has already
// completed 2FA at login, since tokens are only minted after verification when
// 2FA is enabled. Used to exempt in-app re-authorization (e.g. the password
// prompt before on-chain sends) from the TOTP requirement without opening a
// password-only path.
const hasValidAuthToken = (req) => {
try {
const token = req.headers.authorization.split(' ')[1];
jwt.verify(token, common.secret_key);
return true;
}
catch (error) {
return false;
}
};
export const authenticateUser = (req, res, next) => { export const authenticateUser = (req, res, next) => {
const { authenticateWith, authenticationValue, twoFAToken } = req.body; const { authenticateWith, authenticationValue, twoFAToken } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' });
@ -84,8 +102,15 @@ export const authenticateUser = (req, res, next) => {
const failed = getFailedInfo(reqIP, currentTime); const failed = getFailedInfo(reqIP, currentTime);
const password = authenticationValue; const password = authenticationValue;
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) { if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
if (twoFAToken && twoFAToken !== '') { // Gate on the server-side 2FA configuration, not on the request: when 2FA is
if (!verifyToken(twoFAToken)) { // enabled a token is mandatory, so a request omitting twoFAToken is rejected
// instead of silently skipping verification. The login UI keys its token prompt
// on enable2FA, so both fields are consulted — a stale secret with 2FA disabled
// must not lock the operator out of a UI that never prompts for a token.
// Requests with a valid session token (in-app re-authorization, e.g. the
// password prompt before on-chain sends) are exempt from the TOTP requirement.
if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) {
if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } }); logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } });
failed.count = failed.count + 1; failed.count = failed.count + 1;
failed.lastTried = currentTime; failed.lastTried = currentTime;

View file

@ -1,9 +1,12 @@
import exprs from 'express'; import exprs from 'express';
const { Router } = exprs; const { Router } = exprs;
import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js'; import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js';
import { isAuthenticated } from '../../utils/authCheck.js';
const router = Router(); const router = Router();
router.post('/', authenticateUser); router.post('/', authenticateUser);
router.post('/token', verifyToken); router.post('/token', verifyToken);
router.post('/reset', resetPassword); // Password changes mint a fresh session token, so the route requires an existing
// authenticated session; the frontend interceptor attaches it for the settings UI.
router.post('/reset', isAuthenticated, resetPassword);
router.get('/logout', logoutUser); router.get('/logout', logoutUser);
export default router; export default router;

View file

@ -22,22 +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 } { 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) => { this.maskPasswords = (obj) => {
const keys = Object.keys(obj); // Clone up front: masking a live config object must not blank the credentials LN
const length = keys.length; // requests authenticate with (mirrors removeSecureData).
if (length !== 0) { const masked = JSON.parse(JSON.stringify(obj));
for (let i = 0; i < length; i++) { const maskRecursive = (current) => {
if (typeof obj[keys[i]] === 'object') { const keys = Object.keys(current);
keys[keys[i]] = this.maskPasswords(obj[keys[i]]); const length = keys.length;
} if (length !== 0) {
if (typeof keys[i] === 'string' && for (let i = 0; i < length; i++) {
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') || // Header maps always carry credentials in this codebase (macaroon, rune, basic
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') || // auth). Key-substring matching cannot catch them without also hiding the *Path
keys[i].toLowerCase().includes('rpcuser'))) { // fields the settings UI legitimately shows, so mask the whole map.
obj[keys[i]] = '*'.repeat(20); 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 current;
return obj; };
return maskRecursive(masked);
}; };
this.removeAuthSecureData = (node) => { this.removeAuthSecureData = (node) => {
if (node.authentication) { if (node.authentication) {
@ -50,25 +65,55 @@ export class CommonService {
return node; return node;
}; };
this.removeSecureData = (config) => { this.removeSecureData = (config) => {
delete config.rtlConfFilePath; // Clone before deleting: cookieValue is runtime-only, so mutating a caller's live
delete config.rtlPass; // appConfig would destroy SSO state with no way to restore it.
delete config.multiPass; const sanitized = JSON.parse(JSON.stringify(config));
delete config.multiPassHashed; delete sanitized.rtlConfFilePath;
delete config.secret2FA; delete sanitized.rtlPass;
config.nodes?.forEach((node) => this.removeAuthSecureData(node)); delete sanitized.multiPass;
return config; delete sanitized.multiPassHashed;
delete sanitized.secret2FA;
// The SSO cookie is a live bearer credential; it must never leave the server.
if (sanitized.SSO) {
delete sanitized.SSO.cookieValue;
}
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
return sanitized;
}; };
this.addSecureData = (config) => { this.addSecureData = (config) => {
config.rtlConfFilePath = this.appConfig.rtlConfFilePath; config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
config.rtlPass = this.appConfig.rtlPass; 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
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath; // 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, 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) { if (this.appConfig.multiPass) {
config.multiPass = this.appConfig.multiPass; config.multiPass = this.appConfig.multiPass;
} }
if (config.secret2FA === this.appConfig.secret2FA) { // Restore the TOTP seed when the client omits it — and when it sends an empty seed
// 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; config.secret2FA = this.appConfig.secret2FA;
} }
// enable2FA derives from the seed, matching the boot-time derivation in config.ts,
// so the two fields can never diverge after a save.
config.enable2FA = !!config.secret2FA;
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []); const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
config.nodes?.forEach((node) => { config.nodes?.forEach((node) => {
const appConfigNode = appConfigNodes.get(node.index); const appConfigNode = appConfigNodes.get(node.index);
@ -103,7 +148,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err }); this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err });
} }
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' });
return swapOptions; return swapOptions;
}; };
this.getBoltzServerOptions = (req) => { this.getBoltzServerOptions = (req) => {
@ -121,7 +166,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err }); this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err });
} }
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' });
return boltzOptions; return boltzOptions;
}; };
this.getOptions = (req) => { this.getOptions = (req) => {
@ -167,7 +212,7 @@ export class CommonService {
} }
} }
if (req.session.selectedNode) { if (req.session.selectedNode) {
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode });
} }
return { status: 200, message: 'Updated Successfully' }; return { status: 200, message: 'Updated Successfully' };
} }
@ -237,7 +282,7 @@ export class CommonService {
form: '' form: ''
}; };
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
}); });
this.updateSelectedNodeOptions(req); this.updateSelectedNodeOptions(req);
} }
@ -345,10 +390,11 @@ export class CommonService {
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) }); this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) });
let newErrorObj = { statusCode: 500, message: '', error: '' }; let newErrorObj = { statusCode: 500, message: '', error: '' };
if (err.code && err.code === 'ENOENT') { if (err.code && err.code === 'ENOENT') {
// The absolute path stays in the server log above but is not echoed to clients.
newErrorObj = { newErrorObj = {
statusCode: 500, statusCode: 500,
message: 'No such file or directory ' + (err.path ? err.path : ''), message: 'No such file or directory',
error: 'No such file or directory ' + (err.path ? err.path : '') error: 'No such file or directory'
}; };
} }
else { else {

View file

@ -302,7 +302,9 @@ export class ConfigService {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err }); this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err });
} }
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log'; this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) }); // 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(this.common.nodes[idx])) });
const log_file = this.common.nodes[idx].settings.logFile; const log_file = this.common.nodes[idx].settings.logFile;
if (fs.existsSync(log_file || '')) { if (fs.existsSync(log_file || '')) {
fs.writeFile((log_file || ''), '', () => { }); fs.writeFile((log_file || ''), '', () => { });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1712
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "rtl", "name": "rtl",
"version": "0.15.9-beta", "version": "0.15.10-beta",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -16,7 +16,8 @@
"server": "set NODE_ENV=development&&nodemon --watch backend --watch server ./rtl.js", "server": "set NODE_ENV=development&&nodemon --watch backend --watch server ./rtl.js",
"serverUbuntu": "NODE_ENV=development nodemon --watch backend --watch server ./rtl.js", "serverUbuntu": "NODE_ENV=development nodemon --watch backend --watch server ./rtl.js",
"testdev": "ng test --watch=true --code-coverage", "testdev": "ng test --watch=true --code-coverage",
"test": "ng test --watch=false --browsers=ChromeHeadless", "testbackend": "node --test test/backend/*.test.mjs",
"test": "npm run buildbackend && npm run testbackend && ng test --watch=false --browsers=ChromeHeadless",
"lint": "eslint" "lint": "eslint"
}, },
"private": true, "private": true,
@ -26,7 +27,7 @@
"@swimlane/ngx-charts": "23.1.0", "@swimlane/ngx-charts": "23.1.0",
"angular-user-idle": "4.0.0", "angular-user-idle": "4.0.0",
"atob": "2.1.2", "atob": "2.1.2",
"axios": "1.16.0", "axios": "1.18.1",
"buffer": "6.0.3", "buffer": "6.0.3",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"csrf-csrf": "4.0.3", "csrf-csrf": "4.0.3",
@ -54,20 +55,20 @@
"@angular-eslint/eslint-plugin-template": "20.7.0", "@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/schematics": "20.7.0", "@angular-eslint/schematics": "20.7.0",
"@angular-eslint/template-parser": "20.7.0", "@angular-eslint/template-parser": "20.7.0",
"@angular/animations": "20.3.26", "@angular/animations": "20.3.27",
"@angular/build": "20.3.32", "@angular/build": "20.3.32",
"@angular/cdk": "20.2.14", "@angular/cdk": "20.2.14",
"@angular/cli": "20.3.32", "@angular/cli": "20.3.32",
"@angular/common": "20.3.26", "@angular/common": "20.3.27",
"@angular/compiler": "20.3.26", "@angular/compiler": "20.3.27",
"@angular/compiler-cli": "20.3.26", "@angular/compiler-cli": "20.3.27",
"@angular/core": "20.3.26", "@angular/core": "20.3.27",
"@angular/flex-layout": "15.0.0-beta.42", "@angular/flex-layout": "15.0.0-beta.42",
"@angular/forms": "20.3.26", "@angular/forms": "20.3.27",
"@angular/material": "20.2.14", "@angular/material": "20.2.14",
"@angular/platform-browser": "20.3.26", "@angular/platform-browser": "20.3.27",
"@angular/platform-browser-dynamic": "20.3.26", "@angular/platform-browser-dynamic": "20.3.27",
"@angular/router": "20.3.26", "@angular/router": "20.3.27",
"@eslint/eslintrc": "3.3.3", "@eslint/eslintrc": "3.3.3",
"@fortawesome/angular-fontawesome": "4.0.0", "@fortawesome/angular-fontawesome": "4.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0", "@fortawesome/fontawesome-svg-core": "7.1.0",
@ -76,10 +77,10 @@
"@ngrx/store-devtools": "21.0.1", "@ngrx/store-devtools": "21.0.1",
"@types/jasmine": "5.1.15", "@types/jasmine": "5.1.15",
"@types/node": "20.19.30", "@types/node": "20.19.30",
"@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.53.0", "@typescript-eslint/parser": "8.65.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"eslint": "9.39.2", "eslint": "9.39.5",
"eslint-plugin-deprecation": "3.0.0", "eslint-plugin-deprecation": "3.0.0",
"jasmine-core": "5.13.0", "jasmine-core": "5.13.0",
"jasmine-spec-reporter": "7.0.0", "jasmine-spec-reporter": "7.0.0",
@ -89,8 +90,7 @@
"karma-jasmine": "5.1.0", "karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0", "karma-jasmine-html-reporter": "2.1.0",
"material-icons": "1.13.14", "material-icons": "1.13.14",
"nodemon": "3.1.11", "nodemon": "3.1.14",
"protractor": "7.0.0",
"roboto-fontface": "0.10.0", "roboto-fontface": "0.10.0",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"typescript": "5.8.3" "typescript": "5.8.3"

View file

@ -0,0 +1,102 @@
# Release Notes — 0.15.10
This document collects the changes that go into the 0.15.10 release. Each PR merged for
this release should add its entry under the appropriate section below.
## Bug Fixes
- **Auth: harden login request validation**
([#1654](https://github.com/Ride-The-Lightning/RTL/pull/1654)).
Tightens server-side validation of authentication requests and adds regression coverage
(`test/backend/authenticate.test.mjs`). Users who have two-factor authentication enabled
are encouraged to update promptly.
- **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, 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.
- **Eclair: stop logging the node's auth header at DEBUG level**
([#1664](https://github.com/Ride-The-Lightning/RTL/pull/1664)).
`getChannels` in the Eclair channels controller logged its entire request options object,
which for Eclair carries HTTP basic auth — so raising an Eclair node's `logLevel` to
`DEBUG` wrote `authorization: Basic <base64>` into the node log, a recoverable form of the
configured `lnApiPassword`. The log now carries only the request url and form, matching
every other DEBUG log in the controllers. Present since 0.12.0 and only reachable by
opting in to `DEBUG` (the default level is `ERROR`), but it contradicted the logging
guarantee stated for #1659. Regression coverage added
(`test/backend/eclair-channels.test.mjs`). Found by auditing node logs at `DEBUG` while
verifying this release against the regtest fixture.
## Code Health
- **Bound remaining unbounded LND alias-resolution fan-outs**
([#1651](https://github.com/Ride-The-Lightning/RTL/pull/1651), fixes
[#1630](https://github.com/Ride-The-Lightning/RTL/issues/1630)).
Mirrors the `runWithConcurrencyLimit(tasks, 20, done)` pattern introduced in #1629
across the remaining unbounded `Promise.all(map(...))` alias-resolution fan-outs in
the LND graph and channels controllers, preventing a large node from firing one
alias-lookup request per peer, channel, or hop all at once.
During review, a related race condition was found and fixed: the module-level
`options` variable in these controllers was reassigned per-request, but
`getAliasForChannel` and `getAliasFromPubkey` read it by closure rather than
receiving it as a parameter. Once alias-resolution tasks were deferred across
event-loop turns by the concurrency limiter, a concurrent request to a different
node could overwrite `options` mid-fan-out, causing a task to send with the wrong
node's credentials or URL. Both functions now accept an explicit `requestOptions`
parameter, and each handler captures a per-request copy before building the task
thunks. The catch blocks inside the concurrency-limit callbacks were also updated
to log raw exceptions directly instead of routing them through `handleError`
(which expects an HTTP-error-shaped value), matching the existing pattern used
by `closeChannel`.
- **Batch dependency update resolving the open Dependabot security PRs**
([#1653](https://github.com/Ride-The-Lightning/RTL/pull/1653)).
Dependabot had three open security PRs against `master` (#1648, #1649, #1650). Rather than
merging them piecemeal (they conflict with each other on `package-lock.json` and target the
wrong branch for the release flow), the fixes were applied in one pass on the release branch.
The only production exposure was `axios`, carrying ten advisories at 1.16.0 — prototype
pollution in request-option merging, `formDataToJSON` recursion DoS, `maxBodyLength` bypasses
on fetch/HTTP2 uploads, and a `NO_PROXY` bypass — now on 1.18.1 (a patch above Dependabot's
validated 1.18.0, which was superseded during the batch). The lockfile was regenerated from
scratch rather than incrementally patched, and the flagged transitive deps were moved to their
fixed in-range versions (`fast-uri` 3.1.4, plus `form-data`, `qs`, `tough-cookie`, `tar`,
`del` and `globby`). The dev toolchain took safe patch/minor bumps: `nodemon` 3.1.14,
`eslint` 9.39.5, and `@typescript-eslint/*` 8.65.0.
The unused `protractor` devDependency was also dropped. It had been dead since the Angular
scaffold that introduced it — no `e2e/` directory, no `protractor.conf.js`, and no `e2e`
target in `angular.json`, leaving a single line in `package.json` as its only reference —
while dragging in 100 packages and the deprecated `request` stack. Removing it clears both
remaining critical advisories (`request`, `form-data`) along with fourteen others
(`adm-zip`, `selenium-webdriver`, `webdriver-manager`, `xml2js`, `tmp`, `rimraf` and the
rest of the webdriver chain).
`npm audit`: **50 vulnerabilities (2 critical, 37 high, 10 moderate, 1 low) → 29
(0 critical, 23 high, 6 moderate)**, and **production dependencies are now clean at 0**
(from 1 high). Everything still flagged is dev-only build tooling that cannot be fixed by a
version bump: the Angular CLI chain (`@hono/node-server` and `@modelcontextprotocol/sdk`
need Angular 21, i.e. `@angular/core` ^21 and TypeScript ≥5.9 — a framework migration, not a
bump; #1650 is left for that work), the `@angular-eslint` line, and the karma/jasmine stack.
None of it ships in the released bundle.
- **Angular framework patch update to 20.3.27**
([#1661](https://github.com/Ride-The-Lightning/RTL/pull/1661)).
Dependabot opened one PR per package against `master` for `@angular/core` (#1658),
`@angular/compiler` (#1657) and `@angular/common` (#1655). The framework packages are
pinned to exact versions and their peer ranges require them to move as a set, so the three
were applied as a single batch on the release branch, taking all nine 20.3.26 packages
(`animations`, `common`, `compiler`, `compiler-cli`, `core`, `forms`, `platform-browser`,
`platform-browser-dynamic`, `router`) to 20.3.27. Upstream fixes only, no advisories:
the compiler now disallows `i18n` event attributes and limits its possible-event-handler
check to property names longer than two characters, `HttpClient` distinguishes repeated
transfer-cache params, and `platform-server` picks up a newer `domino`.
This stays inside Angular 20 — the build toolchain (`@angular/build`, `@angular/cli`
20.3.32) and `@angular/cdk`/`@angular/material` (20.2.14) are already at the top of their
v20 lines, so nothing in this batch pulls in the Angular 21 migration still tracked by
#1650. `frontend/` was rebuilt for the new framework code.

View file

@ -55,7 +55,10 @@ export const getChannels = (req, res, next) => {
options.form = req.query; options.form = req.query;
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels Node Id', data: options.form });
} }
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: options }); // Log the call's shape only. Eclair authenticates with HTTP basic auth, so the options
// object carries the node's lnApiPassword in its authorization header, and node logs are
// routinely shared when debugging.
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Options', data: { url: options.url, form: options.form } });
if (common.read_dummy_data) { if (common.read_dummy_data) {
common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); }); common.getDummyData('Channels', req.session.selectedNode.lnImplementation).then((data) => { res.status(200).json(data); });
} else { } else {

View file

@ -6,10 +6,10 @@ let options = null;
const logger: LoggerService = Logger; const logger: LoggerService = Logger;
const common: CommonService = Common; const common: CommonService = Common;
export const getAliasForChannel = (selNode: SelectedNode, channel) => { export const getAliasForChannel = (selNode: SelectedNode, channel, requestOptions) => {
const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : ''; const pubkey = (channel.remote_pubkey) ? channel.remote_pubkey : (channel.remote_node_pub) ? channel.remote_node_pub : '';
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((aliasBody) => { return request(requestOptions).then((aliasBody) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias }); logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Channels', msg: 'Alias Received', data: aliasBody.node.alias });
channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20); channel.remote_alias = aliasBody.node.alias && aliasBody.node.alias !== '' ? aliasBody.node.alias : aliasBody.node.pub_key.slice(0, 20);
return channel; return channel;
@ -31,20 +31,23 @@ export const getAllChannels = (req, res, next) => {
request(options).then((body) => { request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Channels', msg: 'Channels List Received', data: body });
if (body.channels) { if (body.channels) {
return Promise.all( body.channels.forEach((channel) => {
body.channels?.map((channel) => { local = (channel.local_balance) ? +channel.local_balance : 0;
local = (channel.local_balance) ? +channel.local_balance : 0; remote = (channel.remote_balance) ? +channel.remote_balance : 0;
remote = (channel.remote_balance) ? +channel.remote_balance : 0; total = local + remote;
total = local + remote; channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3);
channel.balancedness = (total === 0) ? 1 : (1 - Math.abs((local - remote) / total)).toFixed(3); });
return getAliasForChannel(req.session.selectedNode, channel); const selNode = req.session.selectedNode;
}) const { qs: _qs, ...requestOptions } = options;
).then((values) => { const getChannelAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body }); common.runWithConcurrencyLimit(getChannelAliasesTasks, 20, () => {
return res.status(200).json(body); try {
}).catch((errRes) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Sorted Channels List Received', data: body });
const err = common.handleError(errRes, 'Channels', 'Get All Channel Aliases Error', req.session.selectedNode); return res.status(200).json(body);
return res.status(err.statusCode).json({ message: err.message, error: err.error }); } catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get All Channel Aliases Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get All Channel Aliases Error', error: e.message }); }
}
}); });
} else { } else {
body.channels = []; body.channels = [];
@ -67,27 +70,30 @@ export const getPendingChannels = (req, res, next) => {
if (!body.total_limbo_balance) { if (!body.total_limbo_balance) {
body.total_limbo_balance = 0; body.total_limbo_balance = 0;
} }
const promises = []; const selNode = req.session.selectedNode;
const { qs: _qs, ...requestOptions } = options;
const getPendingAliasesTasks = [];
if (body.pending_open_channels && body.pending_open_channels.length > 0) { if (body.pending_open_channels && body.pending_open_channels.length > 0) {
body.pending_open_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_open_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) { if (body.pending_force_closing_channels && body.pending_force_closing_channels.length > 0) {
body.pending_force_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_force_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.pending_closing_channels && body.pending_closing_channels.length > 0) { if (body.pending_closing_channels && body.pending_closing_channels.length > 0) {
body.pending_closing_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.pending_closing_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
if (body.waiting_close_channels && body.waiting_close_channels.length > 0) { if (body.waiting_close_channels && body.waiting_close_channels.length > 0) {
body.waiting_close_channels?.map((channel) => promises.push(getAliasForChannel(req.session.selectedNode, channel.channel))); body.waiting_close_channels?.map((channel) => getPendingAliasesTasks.push(() => getAliasForChannel(selNode, channel.channel, { ...requestOptions })));
} }
return Promise.all(promises).then((values) => { common.runWithConcurrencyLimit(getPendingAliasesTasks, 20, () => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body }); try {
return res.status(200).json(body); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Pending Channels List Received', data: body });
}). return res.status(200).json(body);
catch((errRes) => { } catch (e) {
const err = common.handleError(errRes, 'Channels', 'Get Pending Channel Aliases Error', req.session.selectedNode); logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Pending Channel Aliases Error', error: e.message });
return res.status(err.statusCode).json({ message: err.message, error: err.error }); if (!res.headersSent) { res.status(500).json({ message: 'Get Pending Channel Aliases Error', error: e.message }); }
}); }
});
}).catch((errRes) => { }).catch((errRes) => {
const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode); const err = common.handleError(errRes, 'Channels', 'List Pending Channels Error', req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.message, error: err.error }); return res.status(err.statusCode).json({ message: err.message, error: err.error });
@ -102,17 +108,20 @@ export const getClosedChannels = (req, res, next) => {
options.qs = req.query; options.qs = req.query;
request(options).then((body) => { request(options).then((body) => {
if (body.channels && body.channels.length > 0) { if (body.channels && body.channels.length > 0) {
return Promise.all( body.channels.forEach((channel) => {
body.channels?.map((channel) => { channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type;
channel.close_type = (!channel.close_type) ? 'COOPERATIVE_CLOSE' : channel.close_type; });
return getAliasForChannel(req.session.selectedNode, channel); const selNode = req.session.selectedNode;
}) const { qs: _qs, ...requestOptions } = options;
).then((values) => { const getClosedAliasesTasks = body.channels.map((channel) => () => getAliasForChannel(selNode, channel, { ...requestOptions }));
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body }); common.runWithConcurrencyLimit(getClosedAliasesTasks, 20, () => {
return res.status(200).json(body); try {
}).catch((errRes) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Channels', msg: 'Closed Channels List Received', data: body });
const err = common.handleError(errRes, 'Channels', 'Get Closed Channel Aliases Error', req.session.selectedNode); return res.status(200).json(body);
return res.status(err.statusCode).json({ message: err.message, error: err.error }); } catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Channels', msg: 'Get Closed Channel Aliases Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Closed Channel Aliases Error', error: e.message }); }
}
}); });
} else { } else {
body.channels = []; body.channels = [];

View file

@ -6,9 +6,9 @@ let options = null;
const logger: LoggerService = Logger; const logger: LoggerService = Logger;
const common: CommonService = Common; const common: CommonService = Common;
export const getAliasFromPubkey = (selNode: SelectedNode, pubkey) => { export const getAliasFromPubkey = (selNode: SelectedNode, pubkey, requestOptions) => {
options.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey; requestOptions.url = selNode.settings.lnServerUrl + '/v1/graph/node/' + pubkey;
return request(options).then((res) => { return request(requestOptions).then((res) => {
logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias }); logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Graph', msg: 'Alias Received', data: res.node.alias });
return res.node.alias; return res.node.alias;
}). }).
@ -80,20 +80,23 @@ export const getQueryRoutes = (req, res, next) => {
request(options).then((body) => { request(options).then((body) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Graph', msg: 'Query Routes Received', data: body });
if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) { if (body.routes && body.routes.length && body.routes.length > 0 && body.routes[0].hops && body.routes[0].hops.length && body.routes[0].hops.length > 0) {
return Promise.all(body.routes[0].hops?.map((hop) => getAliasFromPubkey(req.session.selectedNode, hop.pub_key))). const selNode = req.session.selectedNode;
then((values) => { const { qs: _qs, ...requestOptions } = options;
const getRouteAliasesTasks = body.routes[0].hops.map((hop) => () => getAliasFromPubkey(selNode, hop.pub_key, { ...requestOptions }));
common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, (values) => {
try {
body.routes[0].hops?.map((hop, i) => { body.routes[0].hops?.map((hop, i) => {
hop.hop_sequence = i + 1; hop.hop_sequence = i + 1;
hop.pubkey_alias = values[i]; hop.pubkey_alias = typeof values[i] === 'string' ? values[i] : 'Unknown';
return hop; return hop;
}); });
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes with Alias Received', data: body });
res.status(200).json(body); res.status(200).json(body);
}). } catch (e) {
catch((errRes) => { logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Query Routes Error', error: e.message });
const err = common.handleError(errRes, 'Graph', 'Get Query Routes Error', req.session.selectedNode); if (!res.headersSent) { res.status(500).json({ message: 'Get Query Routes Error', error: e.message }); }
return res.status(err.statusCode).json({ message: err.message, error: err.error }); }
}); });
} else { } else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes Received', data: body }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Graph Routes Received', data: body });
return res.status(200).json(body); return res.status(200).json(body);
@ -138,15 +141,19 @@ export const getAliasesForPubkeys = (req, res, next) => {
if (options.error) { return res.status(options.statusCode).json({ message: options.message, error: options.error }); } if (options.error) { return res.status(options.statusCode).json({ message: options.message, error: options.error }); }
if (req.query.pubkeys) { if (req.query.pubkeys) {
const pubkeyArr = req.query.pubkeys.split(','); const pubkeyArr = req.query.pubkeys.split(',');
return Promise.all(pubkeyArr?.map((pubkey) => getAliasFromPubkey(req.session.selectedNode, pubkey))). const selNode = req.session.selectedNode;
then((values) => { const { qs: _qs, ...requestOptions } = options;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: values }); const getAliasesTasks = pubkeyArr.map((pubkey) => () => getAliasFromPubkey(selNode, pubkey, { ...requestOptions }));
res.status(200).json(values); common.runWithConcurrencyLimit(getAliasesTasks, 20, (values) => {
}). try {
catch((errRes) => { const safeValues = values.map((v) => (typeof v === 'string' ? v : 'Unknown'));
const err = common.handleError(errRes, 'Graph', 'Get Aliases for Pubkeys Error', req.session.selectedNode); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Graph', msg: 'Node Alias', data: safeValues });
return res.status(err.statusCode).json({ message: err.message, error: err.error }); res.status(200).json(safeValues);
}); } catch (e) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Graph', msg: 'Get Aliases for Pubkeys Error', error: e.message });
if (!res.headersSent) { res.status(500).json({ message: 'Get Aliases for Pubkeys Error', error: e.message }); }
}
});
} else { } else {
return res.status(200).json([]); return res.status(200).json([]);
} }

View file

@ -1,6 +1,6 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import * as fs from 'fs'; import * as fs from 'fs';
import { sep } from 'path'; import { resolve, sep } from 'path';
import ini from 'ini'; import ini from 'ini';
import parseHocon from 'hocon-parser'; import parseHocon from 'hocon-parser';
import request from '../../utils/request.js'; import request from '../../utils/request.js';
@ -81,7 +81,22 @@ export const getCurrencyRates = (req, res, next) => {
export const getFile = (req, res, next) => { export const getFile = (req, res, next) => {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Getting File..' }); 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: 'Channel Point', data: req.query.channel });
logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file }); logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'RTLConf', msg: 'File Path', data: file });
fs.readFile(file, 'utf8', (errRes, data) => { fs.readFile(file, 'utf8', (errRes, data) => {
@ -91,7 +106,8 @@ export const getFile = (req, res, next) => {
const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode);
return res.status(err.statusCode).json({ message: err.error, error: err.error }); return res.status(err.statusCode).json({ message: err.error, error: err.error });
} else { } else {
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received', data: data }); // File contents can carry node credentials; never write them to the log.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'File Data Received' });
res.status(200).json(data); res.status(200).json(data);
} }
}); });
@ -112,7 +128,6 @@ export const getApplicationSettings = (req, res, next) => {
delete appConfData.SSO.rtlCookiePath; delete appConfData.SSO.rtlCookiePath;
delete appConfData.SSO.cookieValue; delete appConfData.SSO.cookieValue;
delete appConfData.SSO.logoutRedirectLink; delete appConfData.SSO.logoutRedirectLink;
appConfData.secret2FA = '';
appConfData.dbDirectoryPath = ''; appConfData.dbDirectoryPath = '';
appConfData.nodes[selNodeIdx].authentication = new Authentication(); appConfData.nodes[selNodeIdx].authentication = new Authentication();
delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath; delete appConfData.nodes[selNodeIdx].settings.bitcoindConfigPath;
@ -205,7 +220,12 @@ export const updateNodeSettings = (req, res, next) => {
const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8')); const config = JSON.parse(fs.readFileSync(RTLConfFile, 'utf-8'));
const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index)); const node = config.nodes.find((node) => (node.index === req.session.selectedNode.index));
if (node && node.settings) { if (node && node.settings) {
// channelBackupPath anchors getFile's containment root and is documented as a
// config-file-only setting; accepting it from the API would let the caller being
// contained choose the containment base. Pin it to the server-held value.
const serverChannelBackupPath = node.settings.channelBackupPath;
node.settings = { ...node.settings, ...req.body.settings }; node.settings = { ...node.settings, ...req.body.settings };
node.settings.channelBackupPath = serverChannelBackupPath;
if (node.authentication && req.body.authentication) { if (node.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) { if (req.body.authentication.boltzMacaroonPath) {
node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; node.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
@ -222,7 +242,9 @@ export const updateNodeSettings = (req, res, next) => {
fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8'); fs.writeFileSync(RTLConfFile, JSON.stringify(config, null, 2), 'utf-8');
const selectedNode = common.findNode(req.session.selectedNode.index); const selectedNode = common.findNode(req.session.selectedNode.index);
if (selectedNode && selectedNode.settings) { if (selectedNode && selectedNode.settings) {
const serverChannelBackupPath = selectedNode.settings.channelBackupPath;
selectedNode.settings = { ...selectedNode.settings, ...req.body.settings }; selectedNode.settings = { ...selectedNode.settings, ...req.body.settings };
selectedNode.settings.channelBackupPath = serverChannelBackupPath;
if (selectedNode.authentication && req.body.authentication) { if (selectedNode.authentication && req.body.authentication) {
if (req.body.authentication.boltzMacaroonPath) { if (req.body.authentication.boltzMacaroonPath) {
selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath; selectedNode.authentication.boltzMacaroonPath = req.body.authentication.boltzMacaroonPath;
@ -281,7 +303,7 @@ export const updateApplicationSettings = (req, res, next) => {
const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode))); const newOnlyNodes = [...newNodesMap.values()].map((newNode) => JSON.parse(JSON.stringify(newNode)));
runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes]; runtimeConfig.nodes = [...updatedAndExistingNodes, ...newOnlyNodes];
} }
common.appConfig = JSON.parse(JSON.stringify({ const newAppConfig = JSON.parse(JSON.stringify({
...runtimeConfig, ...runtimeConfig,
selectedNodeIndex: config.selectedNodeIndex !== undefined ? selectedNodeIndex: config.selectedNodeIndex !== undefined ?
config.selectedNodeIndex : common.appConfig.selectedNodeIndex, config.selectedNodeIndex : common.appConfig.selectedNodeIndex,
@ -292,21 +314,39 @@ export const updateApplicationSettings = (req, res, next) => {
rtlConfFilePath: common.appConfig.rtlConfFilePath, rtlConfFilePath: common.appConfig.rtlConfFilePath,
rtlPass: common.appConfig.rtlPass rtlPass: common.appConfig.rtlPass
})); }));
const fileConfig = JSON.parse(JSON.stringify(common.appConfig)); const fileConfig = JSON.parse(JSON.stringify(newAppConfig));
delete fileConfig.selectedNodeIndex; delete fileConfig.selectedNodeIndex;
delete fileConfig.enable2FA; delete fileConfig.enable2FA;
delete fileConfig.allowPasswordUpdate; delete fileConfig.allowPasswordUpdate;
delete fileConfig.rtlConfFilePath; delete fileConfig.rtlConfFilePath;
delete fileConfig.rtlPass; delete fileConfig.rtlPass;
delete fileConfig.multiPass; delete fileConfig.multiPass;
// Runtime-only SSO bearer; must not be persisted with the config.
if (fileConfig.SSO) { delete fileConfig.SSO.cookieValue; }
fileConfig.nodes?.forEach((node) => { fileConfig.nodes?.forEach((node) => {
delete node.authentication?.options; delete node.authentication?.options;
delete node.authentication?.runeValue; delete node.authentication?.runeValue;
}); });
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8'); // Persist atomically (temp file + rename, so a mid-write failure cannot truncate the
const newConfig = JSON.parse(JSON.stringify(common.appConfig)); // config) and only then adopt the new runtime config, so a failed write leaves the
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.maskPasswords(newConfig) }); // process on the old one. The temp file inherits the existing file's mode so a
res.status(201).json(common.removeSecureData(newConfig)); // hardened 0600 is not silently downgraded; a fresh file gets 0600. Symlinks and
// single-file bind mounts cannot be renamed over — fall back to an in-place write,
// which preserves inode and mode.
const tempConfigFile = RTLConfFile + '.tmp';
try {
fs.writeFileSync(tempConfigFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
fs.chmodSync(tempConfigFile, fs.existsSync(RTLConfFile) ? (fs.statSync(RTLConfFile).mode & 0o777) : 0o600);
fs.renameSync(tempConfigFile, RTLConfFile);
} catch {
fs.rmSync(tempConfigFile, { force: true, recursive: true });
fs.writeFileSync(RTLConfFile, JSON.stringify(fileConfig, null, 2), 'utf-8');
}
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.
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'RTLConf', msg: 'Application Settings Updated', data: common.removeSecureData(newAppConfig) });
res.status(201).json(common.removeSecureData(newAppConfig));
} catch (errRes) { } catch (errRes) {
const errMsg = 'Update Default Node Error'; const errMsg = 'Update Default Node Error';
const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode); const err = common.handleError({ statusCode: 500, message: errMsg, error: errRes }, 'RTLConf', errMsg, req.session.selectedNode);

View file

@ -21,6 +21,9 @@ const loginInterval = setInterval(() => {
} }
} }
}, LOCKING_PERIOD); }, LOCKING_PERIOD);
// The sweeper must not hold the event loop open on its own (it would keep
// `node --test` or a CLI invocation alive for the full 30-minute period).
loginInterval.unref();
export const getFailedInfo = (reqIP, currentTime) => { export const getFailedInfo = (reqIP, currentTime) => {
let failed = { count: 0, lastTried: currentTime }; let failed = { count: 0, lastTried: currentTime };
@ -49,6 +52,21 @@ const handleMultipleFailedAttemptsError = (failed, currentTime, errMsg) => {
export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && (otplib as any).authenticator.check(twoFAToken, common.appConfig.secret2FA)); export const verifyToken = (twoFAToken) => !!(common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && (otplib as any).authenticator.check(twoFAToken, common.appConfig.secret2FA));
// Mirrors isAuthenticated: a request carrying a valid session JWT has already
// completed 2FA at login, since tokens are only minted after verification when
// 2FA is enabled. Used to exempt in-app re-authorization (e.g. the password
// prompt before on-chain sends) from the TOTP requirement without opening a
// password-only path.
const hasValidAuthToken = (req) => {
try {
const token = req.headers.authorization.split(' ')[1];
jwt.verify(token, common.secret_key);
return true;
} catch (error) {
return false;
}
};
export const authenticateUser = (req, res, next) => { export const authenticateUser = (req, res, next) => {
const { authenticateWith, authenticationValue, twoFAToken } = req.body; const { authenticateWith, authenticationValue, twoFAToken } = req.body;
logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' }); logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Authenticate', msg: 'Authenticating User..' });
@ -80,8 +98,15 @@ export const authenticateUser = (req, res, next) => {
const failed = getFailedInfo(reqIP, currentTime); const failed = getFailedInfo(reqIP, currentTime);
const password = authenticationValue; const password = authenticationValue;
if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) { if (common.appConfig.rtlPass === password && failed.count < ALLOWED_LOGIN_ATTEMPTS) {
if (twoFAToken && twoFAToken !== '') { // Gate on the server-side 2FA configuration, not on the request: when 2FA is
if (!verifyToken(twoFAToken)) { // enabled a token is mandatory, so a request omitting twoFAToken is rejected
// instead of silently skipping verification. The login UI keys its token prompt
// on enable2FA, so both fields are consulted — a stale secret with 2FA disabled
// must not lock the operator out of a UI that never prompts for a token.
// Requests with a valid session token (in-app re-authorization, e.g. the
// password prompt before on-chain sends) are exempt from the TOTP requirement.
if (common.appConfig.enable2FA && common.appConfig.secret2FA && common.appConfig.secret2FA !== '' && !hasValidAuthToken(req)) {
if (typeof twoFAToken !== 'string' || twoFAToken === '' || !verifyToken(twoFAToken)) {
logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } }); logger.log({ selectedNode: req.session.selectedNode, level: 'ERROR', fileName: 'Authenticate', msg: 'Invalid Token! Failed IP ' + reqIP, error: { error: 'Invalid token.' } });
failed.count = failed.count + 1; failed.count = failed.count + 1;
failed.lastTried = currentTime; failed.lastTried = currentTime;

View file

@ -1,12 +1,15 @@
import exprs from 'express'; import exprs from 'express';
const { Router } = exprs; const { Router } = exprs;
import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js'; import { authenticateUser, verifyToken, resetPassword, logoutUser } from '../../controllers/shared/authenticate.js';
import { isAuthenticated } from '../../utils/authCheck.js';
const router = Router(); const router = Router();
router.post('/', authenticateUser); router.post('/', authenticateUser);
router.post('/token', verifyToken); router.post('/token', verifyToken);
router.post('/reset', resetPassword); // Password changes mint a fresh session token, so the route requires an existing
// authenticated session; the frontend interceptor attaches it for the settings UI.
router.post('/reset', isAuthenticated, resetPassword);
router.get('/logout', logoutUser); router.get('/logout', logoutUser);
export default router; export default router;

View file

@ -27,23 +27,37 @@ export class CommonService {
constructor() {} constructor() {}
public maskPasswords = (obj) => { public maskPasswords = (obj) => {
const keys = Object.keys(obj); // Clone up front: masking a live config object must not blank the credentials LN
const length = keys.length; // requests authenticate with (mirrors removeSecureData).
if (length !== 0) { const masked = JSON.parse(JSON.stringify(obj));
for (let i = 0; i < length; i++) { const maskRecursive = (current) => {
if (typeof obj[keys[i]] === 'object') { const keys = Object.keys(current);
keys[keys[i]] = this.maskPasswords(obj[keys[i]]); const length = keys.length;
} if (length !== 0) {
if (typeof keys[i] === 'string' && for (let i = 0; i < length; i++) {
((keys[i].toLowerCase().includes('password') && keys[i] !== 'allowPasswordUpdate') || keys[i].toLowerCase().includes('multipass') || // Header maps always carry credentials in this codebase (macaroon, rune, basic
keys[i].toLowerCase().includes('rpcpass') || keys[i].toLowerCase().includes('rpcpassword') || // auth). Key-substring matching cannot catch them without also hiding the *Path
keys[i].toLowerCase().includes('rpcuser')) // fields the settings UI legitimately shows, so mask the whole map.
) { if (keys[i] === 'headers' && current[keys[i]] && typeof current[keys[i]] === 'object') {
obj[keys[i]] = '*'.repeat(20); 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 current;
return obj; };
return maskRecursive(masked);
}; };
public removeAuthSecureData = (node: SelectedNode) => { public removeAuthSecureData = (node: SelectedNode) => {
@ -58,26 +72,53 @@ export class CommonService {
}; };
public removeSecureData = (config: ApplicationConfig) => { public removeSecureData = (config: ApplicationConfig) => {
delete config.rtlConfFilePath; // Clone before deleting: cookieValue is runtime-only, so mutating a caller's live
delete config.rtlPass; // appConfig would destroy SSO state with no way to restore it.
delete config.multiPass; const sanitized = JSON.parse(JSON.stringify(config));
delete config.multiPassHashed; delete sanitized.rtlConfFilePath;
delete config.secret2FA; delete sanitized.rtlPass;
config.nodes?.forEach((node) => this.removeAuthSecureData(node)); delete sanitized.multiPass;
return config; delete sanitized.multiPassHashed;
delete sanitized.secret2FA;
// The SSO cookie is a live bearer credential; it must never leave the server.
if (sanitized.SSO) { delete sanitized.SSO.cookieValue; }
sanitized.nodes?.forEach((node) => this.removeAuthSecureData(node));
return sanitized;
}; };
public addSecureData = (config: ApplicationConfig) => { public addSecureData = (config: ApplicationConfig) => {
config.rtlConfFilePath = this.appConfig.rtlConfFilePath; config.rtlConfFilePath = this.appConfig.rtlConfFilePath;
config.rtlPass = this.appConfig.rtlPass; 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
config.SSO.rtlCookiePath = this.appConfig.SSO.rtlCookiePath; // 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, 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) { if (this.appConfig.multiPass) {
config.multiPass = this.appConfig.multiPass; config.multiPass = this.appConfig.multiPass;
} }
if (config.secret2FA === this.appConfig.secret2FA) { // Restore the TOTP seed when the client omits it — and when it sends an empty seed
// 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; config.secret2FA = this.appConfig.secret2FA;
} }
// enable2FA derives from the seed, matching the boot-time derivation in config.ts,
// so the two fields can never diverge after a save.
config.enable2FA = !!config.secret2FA;
const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []); const appConfigNodes = new Map(this.appConfig.nodes?.map((node) => [node.index, node]) || []);
config.nodes?.forEach((node) => { config.nodes?.forEach((node) => {
const appConfigNode = appConfigNodes.get(node.index); const appConfigNode = appConfigNodes.get(node.index);
@ -112,7 +153,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err }); this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Loop macaroon Error', error: err });
} }
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options', data: swapOptions }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Swap Options Set' });
return swapOptions; return swapOptions;
}; };
@ -130,7 +171,7 @@ export class CommonService {
this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err }); this.logger.log({ selectedNode: this.selectedNode, level: 'ERROR', fileName: 'Common', msg: 'Boltz macaroon Error', error: err });
} }
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options', data: boltzOptions }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Boltz Options Set' });
return boltzOptions; return boltzOptions;
}; };
@ -179,7 +220,7 @@ export class CommonService {
} }
} }
if (req.session.selectedNode) { if (req.session.selectedNode) {
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode, data: req.session.selectedNode.authentication.options }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Updated Node Options for ' + req.session.selectedNode.lnNode });
} }
return { status: 200, message: 'Updated Successfully' }; return { status: 200, message: 'Updated Successfully' };
} catch (err) { } catch (err) {
@ -247,7 +288,7 @@ export class CommonService {
form: '' form: ''
}; };
} }
this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode, data: node.authentication.options }); this.logger.log({ selectedNode: this.selectedNode, level: 'INFO', fileName: 'Common', msg: 'Set Node Options for ' + node.lnNode });
}); });
this.updateSelectedNodeOptions(req); this.updateSelectedNodeOptions(req);
} }
@ -364,10 +405,11 @@ export class CommonService {
this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) }); this.logger.log({ selectedNode: selectedNode, level: 'ERROR', fileName: fileName, msg: errMsg, error: (typeof err === 'object' ? JSON.stringify(err) : err) });
let newErrorObj = { statusCode: 500, message: '', error: '' }; let newErrorObj = { statusCode: 500, message: '', error: '' };
if (err.code && err.code === 'ENOENT') { if (err.code && err.code === 'ENOENT') {
// The absolute path stays in the server log above but is not echoed to clients.
newErrorObj = { newErrorObj = {
statusCode: 500, statusCode: 500,
message: 'No such file or directory ' + (err.path ? err.path : ''), message: 'No such file or directory',
error: 'No such file or directory ' + (err.path ? err.path : '') error: 'No such file or directory'
}; };
} else { } else {
newErrorObj = { newErrorObj = {

View file

@ -284,7 +284,9 @@ export class ConfigService {
this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err }); this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'Config', msg: 'Something went wrong while creating the backup directory: \n' + err });
} }
this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log'; this.common.nodes[idx].settings.logFile = config.rtlConfFilePath + '/logs/RTL-Node-' + node.index + '.log';
this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'Config', msg: 'Node Config: ' + JSON.stringify(this.common.nodes[idx]) }); // 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(this.common.nodes[idx])) });
const log_file = this.common.nodes[idx].settings.logFile; const log_file = this.common.nodes[idx].settings.logFile;
if (fs.existsSync(log_file || '')) { if (fs.existsSync(log_file || '')) {
fs.writeFile((log_file || ''), '', () => { }); fs.writeFile((log_file || ''), '', () => { });

View file

@ -16,7 +16,7 @@ export const SECS_IN_YEAR = 31536000;
export const DEFAULT_INVOICE_EXPIRY = HOUR_SECONDS * 24 * 7; export const DEFAULT_INVOICE_EXPIRY = HOUR_SECONDS * 24 * 7;
export const VERSION = '0.15.9-beta'; export const VERSION = '0.15.10-beta';
export const API_URL = isDevMode() ? 'http://localhost:3000/rtl/api' : './api'; export const API_URL = isDevMode() ? 'http://localhost:3000/rtl/api' : './api';

View file

@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import jwt from 'jsonwebtoken';
import * as otplib from 'otplib';
import { authenticateUser } from '../../backend/controllers/shared/authenticate.js';
import { Common } from '../../backend/utils/common.js';
const { authenticator } = otplib;
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
const PASSWORD_HASH = 'hashed-password';
const setupAppConfig = (enable2FA, secret2FA) => {
Common.appConfig = {
defaultNodeIndex: 0,
selectedNodeIndex: 0,
rtlConfFilePath: '',
dbDirectoryPath: '',
rtlPass: PASSWORD_HASH,
allowPasswordUpdate: true,
enable2FA: enable2FA,
secret2FA: secret2FA,
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' },
nodes: []
};
Common.selectedNode = null;
Common.nodes = [];
};
// failedLoginAttempts is module-level state in authenticate.js, keyed by the request IP
// from common.getRequestIP, which prefers x-forwarded-for (server/utils/common.ts).
// Unique IPs give each call a fresh counter; tests exercising the counter itself pass an
// explicit ip to share one key across calls.
let ipCounter = 0;
const nextIP = () => '10.0.0.' + (ipCounter = ipCounter + 1);
const mockRequest = ({ twoFAToken, ip, authToken, password } = {}) => {
const headers = { 'x-forwarded-for': ip || nextIP() };
if (authToken) { headers.authorization = 'Bearer ' + authToken; }
return {
body: { authenticateWith: 'PASSWORD', authenticationValue: password || PASSWORD_HASH, twoFAToken: twoFAToken },
session: {},
headers: headers,
connection: {},
socket: {}
};
};
const mockResponse = () => {
const res = { statusCode: null, body: null };
res.status = (code) => {
res.statusCode = code;
return { json: (body) => { res.body = body; } };
};
return res;
};
const mockSessionToken = () => jwt.sign({ user: 'NODE_USER' }, Common.secret_key);
test('rejects login without a 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
for (const missingToken of [undefined, '']) {
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: missingToken }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
}
});
test('rejects login with an invalid 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000' }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
});
test('rejects a non-string 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
// A JSON body can carry an array/object/number. otplib 12.0.1 coerces and rejects these
// (digit regex, then strict === against the string token), but the typeof guard keeps the
// rejection explicit and independent of otplib internals.
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: ['1', '2', '3', '4', '5', '6'] }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
});
test('accepts login with a valid 2FA token when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET) }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('accepts password-only re-authorization from an authenticated session when 2FA is enabled', () => {
// In-app re-authorization (e.g. the password prompt before on-chain sends) carries the
// session JWT via the auth interceptor; that session was itself minted after 2FA.
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken() }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('rejects a wrong password even with an authenticated session when 2FA is enabled', () => {
setupAppConfig(true, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined, authToken: mockSessionToken(), password: 'wrong-hash' }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /Invalid Password/);
});
test('locks out after five failed 2FA attempts, even for a then-valid token', () => {
setupAppConfig(true, TOTP_SECRET);
const ip = nextIP(); // one shared counter key for every attempt in this test
for (let i = 0; i < 4; i++) {
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), res, null);
assert.equal(res.statusCode, 401);
assert.match(res.body.error, /2FA/);
}
const fifth = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '000000', ip: ip }), fifth, null);
assert.equal(fifth.statusCode, 401);
assert.match(fifth.body.error, /locked/);
const sixth = mockResponse();
authenticateUser(mockRequest({ twoFAToken: authenticator.generate(TOTP_SECRET), ip: ip }), sixth, null);
assert.equal(sixth.statusCode, 401);
assert.match(sixth.body.error, /locked/);
});
test('accepts password-only login when 2FA is not configured', () => {
setupAppConfig(false, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('accepts a stale token in the request when 2FA is not configured', () => {
// Pins an intentional behavior change: previously a non-empty twoFAToken with no
// configured secret was rejected (verifyToken short-circuits on the empty secret);
// with no 2FA configured the token is now ignored entirely.
setupAppConfig(false, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: '123456' }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('does not require a token when 2FA is disabled but a stale secret remains', () => {
// The login UI prompts only when enable2FA is set, so enforcing a token on a stale
// secret would lock the operator out of a UI that never asks for one.
setupAppConfig(false, TOTP_SECRET);
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});
test('does not enforce a token when 2FA is enabled without a secret', () => {
// Divergence is only reachable via a crafted settings update; a token could never
// verify against an empty secret, so enforcing would lock everyone out.
setupAppConfig(true, '');
const res = mockResponse();
authenticateUser(mockRequest({ twoFAToken: undefined }), res, null);
assert.equal(res.statusCode, 200);
assert.equal(typeof res.body.token, 'string');
});

View file

@ -0,0 +1,209 @@
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');
});

View file

@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { getChannels } from '../../backend/controllers/eclair/channels.js';
// Eclair authenticates with HTTP basic auth, so the request options carry the node's
// lnApiPassword in the authorization header. A DEBUG log of the whole options object
// therefore writes a recoverable credential into the node log file.
const buildRequest = (logFile) => ({
session: {
selectedNode: {
index: 1,
lnNode: 'eclair-node',
lnImplementation: 'ECL',
authentication: {
options: {
url: '',
rejectUnauthorized: false,
json: true,
headers: { authorization: 'Basic ' + Buffer.from(':super-secret-password').toString('base64') }
}
},
settings: { lnServerUrl: 'http://127.0.0.1:1/', logLevel: 'DEBUG', logFile: logFile }
}
},
query: {}
});
const waitForLog = async (logFile) => {
// logger.log appends asynchronously; give it a few turns to flush.
for (let i = 0; i < 40; i++) {
const contents = readFileSync(logFile, 'utf-8');
if (contents.includes('Channels =>')) { return contents; }
await new Promise((resolve) => setTimeout(resolve, 25));
}
return readFileSync(logFile, 'utf-8');
};
test('getChannels does not write the eclair auth header to the node log at DEBUG level', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ecl-channels-'));
const logFile = join(tempDir, 'RTL-Node-1.log');
writeFileSync(logFile, '');
const req = buildRequest(logFile);
const res = { status: () => ({ json: () => { } }) };
try {
getChannels(req, res, () => { });
const contents = await waitForLog(logFile);
assert.ok(contents.includes('Channels =>'), 'expected the controller to have logged at DEBUG level');
assert.ok(!contents.includes('authorization'), 'auth header key must not reach the node log');
assert.ok(!contents.includes('super-secret-password'), 'lnApiPassword must not reach the node log');
assert.ok(!contents.includes(Buffer.from(':super-secret-password').toString('base64')), 'encoded credential must not reach the node log');
// The diagnostic value of the log — where the call went — is still there.
assert.ok(contents.includes('/channels'), 'request url should still be logged for diagnostics');
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
});

View file

@ -1,10 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join, sep } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { updateApplicationSettings } from '../../backend/controllers/shared/RTLConf.js'; import { updateApplicationSettings, updateNodeSettings, getFile } from '../../backend/controllers/shared/RTLConf.js';
import { Common } from '../../backend/utils/common.js'; import { Common } from '../../backend/utils/common.js';
import { WSServer } from '../../backend/utils/webSocketServer.js'; import { WSServer } from '../../backend/utils/webSocketServer.js';
@ -137,3 +137,466 @@ test('updateApplicationSettings preserves indexed node auth and sanitizes only p
rmSync(tempDir, { force: true, recursive: true }); rmSync(tempDir, { force: true, recursive: true });
} }
}); });
test('updateApplicationSettings keeps the SSO cookie server-side without exposing or persisting it', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-sso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 1, 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,
enable2FA: false,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '', cookieValue: 'live-sso-cookie' }
});
// The request carries only what the sanitized client can have seen: no cookieValue.
// The server must re-attach it — a settings save must never wipe the live cookie —
// while keeping it out of both the response and the persisted file.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: false,
allowPasswordUpdate: true,
SSO: { rtlSSO: 1, rtlCookiePath: '/cookie-path', logoutRedirectLink: '' }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
let responseBody;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return {
json: (body) => {
responseBody = body;
}
};
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.rtlCookiePath, '/cookie-path');
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(responseBody.SSO.cookieValue, undefined);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings restores omitted secret2FA and merges a trimmed SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-secrets-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const runtimeConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
secret2FA: 'live-totp-seed',
SSO: { rtlSSO: 0, rtlCookiePath: '/cookie-path', logoutRedirectLink: 'https://logout.example', cookieValue: 'live-sso-cookie' }
});
// Sanitized responses carry neither secret2FA nor cookieValue, so an echoing client
// omits both; a trimmed SSO object also lacks logoutRedirectLink. All three must
// survive the save server-side.
const requestBody = {
...clone(oldConfig),
selectedNodeIndex: 0,
enable2FA: true,
allowPasswordUpdate: true,
SSO: { rtlSSO: 0 }
};
try {
Common.appConfig = clone(runtimeConfig);
Common.nodes = clone(runtimeConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: clone(requestBody), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(Common.appConfig.secret2FA, 'live-totp-seed');
assert.equal(Common.appConfig.enable2FA, true);
assert.equal(Common.appConfig.SSO.cookieValue, 'live-sso-cookie');
assert.equal(Common.appConfig.SSO.logoutRedirectLink, 'https://logout.example');
assert.equal(Common.appConfig.SSO.rtlSSO, 0);
const fileConfig = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8'));
assert.equal(fileConfig.SSO.cookieValue, undefined);
assert.equal(fileConfig.secret2FA, 'live-totp-seed');
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings tolerates a request body without an SSO object', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nosso-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
const requestBody = clone(oldConfig);
delete requestBody.SSO;
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus;
updateApplicationSettings(
{ body: requestBody, session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(typeof Common.appConfig.SSO, 'object');
} finally {
clearInterval(WSServer.pingInterval);
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');
// Both write paths must fail: a read-only dir defeats the temp-file write, and a
// read-only file defeats the in-place fallback.
chmodSync(confPath, 0o444);
chmodSync(tempDir, 0o555);
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(confPath, 0o644);
chmodSync(tempDir, 0o755);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings preserves the config file mode across the atomic write', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-mode-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600); // operator-hardened; must not be silently downgraded
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateApplicationSettings falls back to an in-place write when the rename fails', () => {
// Single-file bind mounts and symlinks cannot be renamed over; the save must still work.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-fallback-'));
const confPath = join(tempDir, 'RTL-Config.json');
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY' }
}
]
};
try {
Common.appConfig = clone({
...oldConfig,
selectedNodeIndex: 0,
rtlConfFilePath: tempDir,
rtlPass: 'hashed-password',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '', cookieValue: '' }
});
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(confPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
chmodSync(confPath, 0o600);
mkdirSync(confPath + '.tmp'); // forces the temp write to fail, exercising the fallback
let responseStatus = null;
updateApplicationSettings(
{ body: clone(oldConfig), session: { selectedNode: Common.selectedNode } },
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
assert.equal(statSync(confPath).mode & 0o777, 0o600); // in-place write keeps the inode
assert.deepEqual(JSON.parse(readFileSync(confPath, 'utf-8')).nodes.length, 1);
} finally {
clearInterval(WSServer.pingInterval);
rmSync(tempDir, { force: true, recursive: true });
}
});
test('updateNodeSettings pins channelBackupPath to the server-held value', () => {
// channelBackupPath anchors getFile's containment root; accepting it from the request
// would let the caller being contained choose the containment base.
const tempDir = mkdtempSync(join(tmpdir(), 'rtlconf-nodesettings-'));
const oldConfig = {
defaultNodeIndex: 0,
dbDirectoryPath: '/db',
SSO: { rtlSSO: 0, rtlCookiePath: '', logoutRedirectLink: '' },
nodes: [
{
index: 0,
lnNode: 'lnd-main',
lnImplementation: 'LND',
authentication: { macaroonPath: '/lnd/admin' },
settings: { userPersona: 'OPERATOR', themeMode: 'DAY', channelBackupPath: '/server/backups' }
}
]
};
try {
Common.appConfig = clone({ ...oldConfig, rtlConfFilePath: tempDir });
Common.nodes = clone(oldConfig.nodes);
Common.selectedNode = Common.nodes[0];
writeFileSync(join(tempDir, 'RTL-Config.json'), JSON.stringify(oldConfig, null, 2), 'utf-8');
let responseStatus = null;
updateNodeSettings(
{
body: { settings: { themeMode: 'NIGHT', channelBackupPath: tempDir } },
session: { selectedNode: Common.nodes[0] }
},
{
status: (status) => {
responseStatus = status;
return { json: () => {} };
}
},
null
);
assert.equal(responseStatus, 201);
const fileNode = JSON.parse(readFileSync(join(tempDir, 'RTL-Config.json'), 'utf-8')).nodes[0];
assert.equal(fileNode.settings.channelBackupPath, '/server/backups');
assert.equal(fileNode.settings.themeMode, 'NIGHT'); // other settings still merge
assert.equal(Common.nodes[0].settings.channelBackupPath, '/server/backups');
} finally {
clearInterval(WSServer.pingInterval);
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 });
}
});